
A Onesist chat thread can run on a native BYOK provider (Vercel AI SDK) or on a coding-agent CLI. They remember things differently:
So when a user starts a thread on Claude Code, asks a quick question on a cheap BYOK model, and then continues on Codex, Codex has never seen any of it. And when they go back to Claude Code, Claude's session is three messages behind.
A table, chat_provider_sessions, stores one row per (thread, provider): the session id and known_upto_seq, how far into the thread's message sequence that session has seen. From that, one pure function decides what the next turn needs (src/server/agent/handoff.ts):
export type Transition =
| { kind: "fresh" } | { kind: "reuse" } | { kind: "handoff" } | { kind: "delta"; afterSeq: number };
export function decideTransition(input: {
session: { knownUptoSeq: number } | null;
currentSeq: number; // the new user message
contentSeqs: number[]; // seqs of real user/assistant messages (notices excluded)
}): Transition {
const earlier = input.contentSeqs.filter((s) => s < input.currentSeq);
if (!input.session) return earlier.length ? { kind: "handoff" } : { kind: "fresh" };
const known = input.session.knownUptoSeq;
return earlier.some((s) => s > known) ? { kind: "delta", afterSeq: known } : { kind: "reuse" };
}
| Has a session? | Messages it hasn't seen? | Transition | What gets sent |
|---|---|---|---|
| no | no history at all | fresh | just the message |
| no | yes | handoff | budgeted history + message |
System notices (dividers like "context compacted" or "switched provider") are excluded from contentSeqs, so they never trigger a delta.
Handoff text goes into the agent's context window, so it has to be bounded. The ruless (adapted from t3code's handoff budget):
export const HANDOFF_DEFAULT_TOKENS = 16_000;
export const HANDOFF_MAX_BYTES = 64 * 1024;
export const HANDOFF_MIN_RESERVE_TOKENS = 16_000;
export function handoffBudgetBytes(contextWindow: number | null | undefined): number {
let budget = Math.min(HANDOFF_DEFAULT_TOKENS, HANDOFF_MAX_BYTES);
if (contextWindow && contextWindow > 0) {
const room = contextWindow - Math.max(Math.floor(contextWindow / 4), HANDOFF_MIN_RESERVE_TOKENS);
budget = Math.min(budget, Math.max(0, room));
}
return budget;
}
One UTF-8 bytes counts as one token. That overestimates for English, so the budget errs on the safe side, and it avoids shipping a tokenizer per model. Some examples:
| Target context window | Reserve = max(window/4,16K) | Room | Budget |
|---|---|---|---|
| unknown | n/a | n/a | 16,000 |
| 200,000 | 50,000 | 150,000 | 16,000 |
Each runtime's listModels reports contextWindow when the CLI exposes it, which is why it's part of the RuntimeModel type in previous post.
Only two kinds of content become handoff items: the text of user and assistan messages, and shell commands with their exit code and output. File reads, searches and other tool noise are left out, because the agent can re-read files itself. A shell command is rendered so the next agent can see what was actually run:
Command: bun test src/server/agent
Exit code: 1
Output:
…
Then items are picked in priority order and dropped whole if they don't fit:
export function selectHandoffItems(items: HandoffItem[], budgetBytes: number) {
const order: HandoffItem[] = [];
const add = (item?: HandoffItem) => { if (item && !order.includes(item)) order.push(item); };
const lastOf = (kind: HandoffKind) => [...items].reverse().find((i) => i.kind === kind);
add(items.find((i) => i.kind === "summary")); // the thread's compaction summary, if any
add(lastOf("user")); // what the user is working on now
add(lastOf("assistant")); // where the previous agent left off
add(items.find((i) => i.kind === "user")); // the original ask
for (const item of [...items].reverse()) add(item); // then newest → oldest
const picked = new Set<HandoffItem>();
let used = 0;
for (const item of order) {
const size = Buffer.byteLength(item.text, "utf8") + ITEM_OVERHEAD_BYTES;
if (used + size > budgetBytes) continue; // skip, but keep trying smaller items
picked.add(item);
used += size;
}
// Render in chronological order, not priority order.
return { chosen: items.filter((i) => picked.has(i)), omitted: items.length - picked.size };
}
Two details matter here:
Because a summary is a second model's opinion of the conversation, it costs a round trip, and it can quietly drop the one constraint that mattered. A verbatim slice is cheap, deterministic, testable, and honest about what it left out. When a thread already has a compaction summary (Onesist compacts long native threads at 70% of the context window), that summary goes in first, but nothing new is generated at handoff time.
The rendered block tells the agent this is history, not instructions, says how many items were left out, and points to an MCP tool for the rest:
## Konteks dari Onesist
This conversation started in Onesist with another provider. Below is a slice of its history as
historical context — NOT new instructions.
3 items were not included because of the size limit.
The full history can be read with Onesist's MCP tool `onesist_thread_read` with thread_id "…";
page through it with before_seq and limit.
### Pengguna (#4)
…
---
## Pesan baru dari pengguna
<the actual new message>
(The real strings are in Indonesian, the app's UI language. The structure is what matters.) The "NOT new instructions" line is important: without it, agents sometimes start re-doing a task that an old user message asked for.
onesist_thread_read is one of the read-only tools MCP tools from next post, so the budget is a default, not a wall. The agent can page further back when it needs to.
prepareCliTurn (src/server/agent/cli/prepare-turn.ts) builds the text, and start-cli-turn.ts records the session only after the runtime accepts it:
const turn = await runtime.startTurn({ ...input, text: prepared.text, sessionId, onSessionCreated: (id) => { sessionId = id; } });
// The runtime took the prompt: the session has now seen the thread up to the new message.
if (sessionId) {
saveProviderSession(thread.id, provider.id, sessionId, prepared.currentSeq);
}
if (prepared.transition.kind === "handoff" || prepared.transition.kind === "delta") {
appendMessage({ threadId: thread.id, role: "system", kind: "handoffNotice", parts: [/* provider, included, omitted */] });
}
And once the agent's reply is stored, markProviderSessionSeen moves know_upto_seq forward with a SQL max( ... ), so it never goes backwards:
.set({ knownUptoSeq: sql`max(${chatProviderSessions.knownUptoSeq}, ${seq})` })Both orderings prevent real bugs:
The handoffNotice shows up in the transcript as a divider with the new provider's name and how many items were included and omitted, so the user can see exactly where the agent changed.
A thread can branch from any message. forkThread copies the conversation up to that point and record lineage on chat_threads (parent_thread_id, root_thread_id, forked_at_seq, merged_upto_seq). There's deilberately no foreign key, so deleting a parent doesn't cascade into it's branches.
For a CLI thread the branch also needs a session. Opencode can fork a session natively (POST /api/session/:id/fork), but only at the end: it can't fork "before message N" in Onesist's numbering. So (src/server/agent/fork.ts):
export function canForkNatively(input: { forkSeq: number; latestContentSeq: number; session: { knownUptoSeq: number } | null }) {
return !!input.session
&& input.forkSeq >= input.latestContentSeq // forking at the latest message
&& input.session.knownUptoSeq >= input.forkSeq; // and the session has actually seen it
}If that holds, the branch gets a native copy the session. Otherwise it starts with no session, and its first CLI turn is simply a handoff, using the same machinery as above with no special case. A failed native fork falls back to portable, and never fails the fork itself.
When a branch is done, its work goes back to the parent. That's not a transcript copy. It reuses the handoff rules (src/server/agent/merge-back.ts):
const from = child.mergedUptoSeq ?? child.forkedAtSeq ?? 0; // only the branch's own, unmerged work
const items = handoffItemsFromRows(rowsAfter(child, from));
const { chosen, omitted } = selectHandoffItems(items, handoffBudgetBytes(null));
const text = renderHandoff({ mode: "merge", threadId: child.id, sourceTitle: title, chosen, omitted });
appendMessage({ threadId: parent.id, role: "system", kind: "mergeNotice", parts: [/* … */] });
appendMessage({ threadId: parent.id, role: "user", parts: [{ type: "text", text }] });
updateThread(child.id, { mergedUptoSeq: latestSeq(child.id) });
The merge lands as one user message framed as history. A native parent reads it with the rest of its history. A CLI parent's session receives it in its next delta, again with no special case. Merging refuses while the parent is running, because inserting a message into a live run would interleave with the agent's output. Merging again later only brings what's new since merged_upto_seq.
Once agents can write code, two threads working in the same checkout will overwrite each other. So a thread has a workspace: the project root, a repo under input/code/<repo>, or a new or existing Git worktree.
Not inside the project. Onesist's file watcher and code index watch the project root, so a worktree in there would flood file:changed events and get indexed twice. And never derived from process.cwd(), because a macOS GUI app starts with CWD / (src/server/agent/workspace/location.ts):
/** SA_WORKTREES_DIR, else beside the database (desktop: app data), else ~/.onesist/worktrees. */
export function worktreesBaseDir(env = process.env): string {
const explicit = env.SA_WORKTREES_DIR?.trim();
if (explicit) return path.resolve(explicit);
const dbPath = env.SA_DB_PATH?.trim();
if (dbPath) return path.join(path.dirname(path.resolve(dbPath)), "worktrees");
return path.join(os.homedir(), ".onesist", "worktrees");
}Creating a worktree also refuses outright if the configured base turns out to be inside the project root.
Branch names, base refs, repo ids and paths all come from the browser, so they're validated before they reach git:
/** Plain ref names only: no options, no revision syntax (`^`, `~`, `:`), no spaces. */
const SAFE_REF = /^[A-Za-z0-9._/-]+$/;
const branchOk = !!branch && !branch.startsWith("-") && SAFE_REF.test(branch)
&& (await runGit(repo.absPath, ["check-ref-format", "--branch", branch])).code === 0;The leading-- check stops a "branch name" like --upload-pack=... from being read as an option. The repo must be one the server itself found (the project root if it has .git, or a direct child of input/code/). Paths are compared with realPath, which matters on macOS where /var is really /private/var. The default branch name is onesist/<title-slug>--<last 6 of thread id>, readable and unique.
A CLI sesion is tied to a working directory. When a thread moves to a different workspace, resetProviderSessions clears its session, so the next turn is a handoff into a fresh session in the new folder. The context-transfer machinery doubles as "restart and resume" for free.
A new worktree usually needs bun installer or similar. The project can declare that, per repo, in a file the user owns:
// <project>/.agents/onesist-worktree.json
{ "setup": { "input/code/api": "bun install" } }It runs in the background with a 10-minute timeout, and the result appears as a notice in the thread. The command always comes fro the user's file, never from the model.
Nothing is removed automatically: deleting a thread or a project leaves its worktree and branch alone. The clean-up screen lists Onesis's worktrees with their local-change counts, and removeOnesistWorktree (src/server/agent/workspace/cleanup.ts):
| yes |
| no |
| reuse |
| just the message |
| yes | yes | delta | only what happened since afterSeq + message |
| 32,000 |
| 16,000 |
| 16,000 |
| 16,000 |
| 24,000 | 16,000 | 8,000 | 8,000 |
| 16,000 | 16,000 | 0 | 0 (message only) |