Skip to content
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **Forks, backend switches and rewinds continue the conversation natively on
Claude and Codex** (#387). Until now a fork onto Claude, Codex or pi got a
text transcript in its first prompt, with each tool's input and output cut
to 2,000 characters and reasoning dropped, even when the fork stayed on the
same backend. Now:
- On the same backend, the backend forks its own conversation at the turn,
so nothing is converted or cut and reasoning is kept. Claude uses the
Agent SDK's `forkSession`; Codex uses `thread/fork`.
- From another backend, codeoid hands the target native history: a Claude
session file with real `tool_use`/`tool_result` blocks, verified with the
SDK's own reader before use, or Codex history items through
`thread/inject_items`.

The transcript remains the fallback, for pi, the Gemini CLI, qwen, and any native
path that fails. The session says which way it went, and
`session.nativeContinuation` / `CODEOID_NATIVE_CONTINUATION` turns this off.
Each turn now records where it ends in the backend's own conversation
(`NormalizedTurnResult.nativeRef`, stamped on the canonical turn).

- **Routines: agents that run on their own** (#54). `codeoid routine add`
saves an agent task that runs on a cron or interval schedule (IANA
timezones, phase-anchored intervals, 60-second floor), once at a set time, from
Expand Down Expand Up @@ -144,6 +163,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **Codex sessions keep their conversation across a daemon restart.** The
Codex provider never saved the thread id it created, so after any restart it
tried to resume a stale id and silently started an empty thread. It now
saves the id the moment it adopts a thread. A backend switch now also marks
the new backend fresh until its first prompt, so a restart in between
re-seeds it instead of resuming nothing.

- **Claude's "No conversation found" recovery keeps the conversation.** When
a backing session can't be resumed, recovery used to start a blank session
and re-send only the latest prompt. It now hands the new session the
conversation before that prompt (natively where it can), and can't loop if
the new backing is missing too.

- **The context window is no longer guessed from the model id.**
codeoid inferred every window from a substring table over model ids, and that table was wrong the moment a model shipped: `claude-opus-5-5` inferred to the 200k fallback while every turn result stated 1,000,000.
The percent-of-window display and the history seed sized for a fork or a provider switch were both built on that guess.
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ Every prompt, and everything the agent does in reply, belongs to one turn with a
A message sent mid-turn joins the running turn.
At the start of each turn the working directory is snapshotted into a daemon-owned shadow git repo (`<transcriptDir>/checkpoints/<session>.git`, self-contained, never the user's repo; secrets and dependency dirs excluded; works without git).
The backend-neutral canonical history is persisted per session (`<id>.canonical.jsonl`) so forks, backend switches and stateless backends keep the conversation across a daemon restart.
All of it is backend-agnostic: nothing depends on a provider's native session.
It is the source of truth, and it doesn't depend on a provider's native session.
What a backend is handed on a fork, switch or rewind is chosen by `Session.#seedProviderFromHistory` (#387): `continueNatively` first — Claude and Codex fork their own conversation at the turn's recorded `native` point (stamped on each canonical assistant turn from `NormalizedTurnResult.nativeRef`), or take native history built from the canonical turns — then the transcript seed as the fallback.

### Device Handoff
Sessions live in the daemon. Clients are stateless.
Expand Down
2 changes: 1 addition & 1 deletion docs/COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Codeoid is the latter, which is why the rows below weigh memory, attribution, an
| **Multi-harness** (multiple agent backends) | ❌ Claude only | ❌ | ❌ | ❌ | ✅ swap/combine harnesses in one session (also Cursor, OpenCode, Hermes) | ✅ 12 built-in + any CLI agent, as terminal processes | ✅ Claude, Codex, Gemini, OpenAI, pi, Gemini CLI — per session + fork across backends |
| **Cost of adding a backend** | — | — | — | ~ model config | PTY wrapper per harness (tmux) | ~8-line declarative manifest (command + prompt transport) | 300–1,300 LOC native provider, or a thin ACP adapter |
| **Structured agent event model** (tool calls, subagent lineage, task graph) | ✅ native SDK | — | — | ❌ | ❌ PTY bytes | ~ terminal path is opaque bytes; ACP path is structured but drops subagent identity, task dependencies, and workflows | ✅ canonical `ProviderEvent` — tool calls, `parentToolUseId` lineage, normalized usage |
| **Cross-backend session fork** (switch harness mid-session, keep context) | ❌ | — | — | ❌ | ✅ swap/combine within one session | ❌ a session is bound to the agent that started it | ✅ `session.set_provider` / `session.fork` with history seed |
| **Cross-backend session fork** (switch harness mid-session, keep context) | ❌ | — | — | ❌ | ✅ swap/combine within one session | ❌ a session is bound to the agent that started it | ✅ `session.set_provider` / `session.fork`; Claude and Codex continue natively (their own fork, or native history codeoid builds); OpenAI, Gemini and llama.cpp replay full history natively every turn; pi, the Gemini CLI and Qwen from a transcript |
| **Built-in diff review + PR workflow** | ❌ | ✅ | ✅ | ~ | ❌ | ✅ editable diff viewer, inline comments, commit/push, PR status | ❌ not built — roadmap |
| **Remote multi-machine reach** | ~ hosted Remote Control (Max plan) | ❌ | ❌ | ❌ | ~ server deploy | ✅ host registry + cloud relay routes by host key; wakes offline hosts | ~ one daemon, reached directly or through Telegram; no relay |
| **Scheduled / unattended automations** | ~ routines (research preview): Anthropic cloud only, 1-hour minimum interval | ❌ | ~ Automations, in Cursor's cloud | ❌ | ~ | ✅ cron-scheduled agent sessions, versioned | ✅ [routines](routines.md): cron / interval / API trigger, a zero-cost check gate, `[SILENT]`, per-run + daily spend caps, guarded or budgeted approvals, failure auto-pause — on your own machine, any backend |
Expand Down
3 changes: 3 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ CODEOID_MCP_CONTROL_ACTIONS=false # also allow create / send / interrupt /
CODEOID_ROUTINES_ENABLED=true # run the scheduler and accept routine commands
CODEOID_ROUTINES_MAX_CONCURRENT=2 # routine runs executing at once, across all routines

# Forks, backend switches and rewinds (docs/FEATURES.md#how-a-fork-switch-or-rewind-carries-the-conversation)
CODEOID_NATIVE_CONTINUATION=true # hand Claude/Codex the conversation in their own form; false = text transcript

# Memory
CODEOID_MEMORY=1 # default: on; set to 0 to disable
CODEOID_MEMORY_DB_PATH=~/.codeoid/memory.db
Expand Down
21 changes: 21 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ A session's *harness* (the agent backend it drives) is chosen when the session i

Pick a harness when you create a session — the New Session modal in the web UI or TUI shows the choice whenever more than one backend is registered — or **fork across backends**: fork a Claude session onto Codex and it resumes with the parent's full conversation, so one backend can pick up exactly where another left off. A backend that isn't authenticated or installed simply doesn't appear as an option (no cryptic first-turn 401s).

#### How a fork, switch or rewind carries the conversation

Files are the checkpoint's business: a fork in its own worktree starts from the turn's checkpoint, and a rewind can restore files too.
The conversation is handed over in the backend's own form wherever the backend allows it, and as a text transcript only where it doesn't:

| Going to | Same backend (fork, rewind) | From another backend (fork, switch) |
| --- | --- | --- |
| `claude` | Claude forks its own session at that turn: nothing converted or cut, reasoning included | codeoid writes the conversation as a Claude session, real tool calls and results, and checks it with the SDK's own reader before using it |
| `codex` | Codex forks its own thread through that turn | codeoid injects the conversation into a new thread as Codex's own history items |
| `openai`, `gemini`, `llamacpp` | the full history, natively, every turn | the full history, natively, every turn |
| `pi`, `gemini-cli`, `qwen` | a text transcript in the first prompt | a text transcript in the first prompt |

Reasoning never crosses vendors (Claude's is signed, OpenAI's encrypted).
History codeoid builds that doesn't fit the target's window loses its oldest turns, with a notice; a native fork isn't used when the source was already running near the target's window, nor from a turn that ended in an error, so a fork is the way out of an overflowed session rather than a copy of it.
On a fork or switch the session says which way it went (`↪ … continues its own conversation`, `↪ … was given … as its own history`, or `↪ … carried as a text transcript`), and if a native path fails it falls back to the transcript and says so.
History a `tool_result` [hook](hooks.md) redacted is never forked natively — a fork would copy the backend's unredacted log — so codeoid's redacted record is used instead.
Turns rebuilt from a displayed transcript, such as an imported session, are never built into native history (they aren't the model's own words, so they stay quoted); a native fork from a real turn after them is still used.
Like every Claude session codeoid runs, a session it writes or forks for Claude lives under `projects/` in `CLAUDE_CONFIG_DIR` (default `~/.claude`), and is subject to Claude Code's own `cleanupPeriodDays`.
`session.nativeContinuation: false` (or `CODEOID_NATIVE_CONTINUATION=false`) keeps everything on the transcript.
Native continuation is tried before the `CODEOID_CONTEXT_STRATEGY` seed (`transcript` or `vws`), which still applies to every backend it doesn't cover.

Memory, identity, attachments, the multi-frontend clients, and device handoff are all provider-agnostic. A few features lean on Claude Code internals and so apply only to the `claude` harness: backing-session [auto-rotation](#context-reduction-stack), Claude Code slash-command [pass-through](#terminal-client), and `.claude/commands` workspace commands.

### Terminal client
Expand Down
3 changes: 2 additions & 1 deletion docs/providers-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ The bridge announces itself on session start; **if it fails to load, turns fail
`/provider pi` (or `session.set_provider`) swaps a live session's backend while keeping the session id, scrollback, transcript, and identity.
The conversation so far is carried into the incoming backend as a structured-text transcript — tool calls rendered as structured blocks (name, input JSON, fenced output), oldest turns dropped first past ~24k chars.
Stateless backends (gemini, openai) go further: they replay the history in their native function-call structure on every request.
Warm backends (claude, pi) stay a faithful transcript, **not** a native continuation — neither the Claude Agent SDK nor pi RPC accepts synthesized native-history injection, so prompt-cache state and extension state don't cross.
Claude and Codex continue natively — their own fork on the same backend, native history from another (see [how a fork, switch or rewind carries the conversation](FEATURES.md#how-a-fork-switch-or-rewind-carries-the-conversation)).
pi stays a faithful transcript, **not** a native continuation: its RPC has no history-import or fork entry point, so prompt-cache state and extension state don't cross.
Switching resets the model to the new backend's default, and mid-turn switches are rejected — interrupt first.
`/provider claude` brings the session back the same way.
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,13 @@ const SessionSchema = z
seedBudgetChars: z.number().int().min(0).default(0),
seedCharsPerToken: z.number().positive().default(2),
seedWindowFraction: z.number().gt(0).lt(1).default(0.7),
/**
* Hand the conversation to backends that can take it in their own form
* (#387) — Claude and Codex fork their own conversation at the turn, or
* take native history codeoid builds — instead of the transcript above.
* The transcript is still the fallback. Off = transcript everywhere.
*/
nativeContinuation: z.boolean().default(true),
})
.default({
turnStallTimeoutMs: 300_000,
Expand All @@ -455,6 +462,7 @@ const SessionSchema = z
seedBudgetChars: 0,
seedCharsPerToken: 2,
seedWindowFraction: 0.7,
nativeContinuation: true,
})
// Enforce the "SDK signals first" contract across BOTH fields — not just the
// defaults. An env override / config file could otherwise set the MCP timeout
Expand Down Expand Up @@ -1275,6 +1283,8 @@ export interface CodeoidConfig {
seedCharsPerToken?: number;
/** Fraction (0..1) of the target window the history seed may fill. Defaults to 0.7 when omitted. */
seedWindowFraction?: number;
/** Continue forks / switches / rewinds in the backend's own conversation where it can (#387). Default true. */
nativeContinuation?: boolean;
};
/**
* The per-tenant conductor session (fleet supervisor). Optional in the
Expand Down Expand Up @@ -1537,6 +1547,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [
{ env: "CODEOID_SEED_BUDGET_CHARS", path: "session.seedBudgetChars", kind: "int" },
{ env: "CODEOID_SEED_CHARS_PER_TOKEN", path: "session.seedCharsPerToken", kind: "float" },
{ env: "CODEOID_SEED_WINDOW_FRACTION", path: "session.seedWindowFraction", kind: "float" },
{ env: "CODEOID_NATIVE_CONTINUATION", path: "session.nativeContinuation", kind: "boolean" },
// Embed-SSO trusted framing origins (comma-separated). Each is an exact
// origin (scheme://host[:port]) permitted to frame the web UI and hand it a
// credential via the URL hash. Empty ⇒ hash handoff disabled (safe default).
Expand Down
1 change: 1 addition & 0 deletions src/daemon/canonical-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function canonicalFromTranscript(messages: readonly DaemonMessage[], prov
...(thinking ? { thinking } : {}),
providerId,
model: "unknown",
restored: true,
});
}
text = [];
Expand Down
Loading
Loading