Full feature reference for Codeoid. For install and a quick tour, start with the README.
A session's harness (the agent backend it drives) is chosen when the session is created and can be changed by forking. Codeoid ships six, all behind one SessionProvider interface — adding a backend is one factory plus one register() call, and nothing else in the daemon changes.
Harness (providerId) |
Backed by | Available when |
|---|---|---|
claude (default) |
Claude Agent SDK | always |
openai |
OpenAI API | OPENAI_API_KEY is set |
gemini |
Google Gemini API | GOOGLE_API_KEY is set |
codex |
Codex CLI (app-server) | codex resolves on PATH (or the bundled binary) |
pi |
pi CLI | pi resolves on PATH (or the bundled binary) |
gemini-cli |
Gemini CLI (ACP) | gemini resolves on PATH |
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).
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, Claude Code slash-command pass-through, and .claude/commands workspace commands.
Codeoid has two terminal cockpits, both speaking the daemon's WebSocket protocol:
- codeoid-tui (recommended) — a native
Rust/Ratatui client in its own repo. A true cell-matrix
framebuffer, so it stays jitter-free under high-frequency streaming deltas.
Build once (
cargo run -p codeoid-tui --release) and point it at the daemon. - Built-in
codeoid tui(Ink/React) — ships in this repo as a zero-install fallback. Documented below. Same protocol, same daemon; codeoid-tui supersedes it.
bun src/cli.ts tui launches the built-in Ink cockpit with everything in one view:
[▾ studio2 @ /Workspace/codeoid]
You
use the Explore agent to survey src/daemon and summarize
Claude
I'll use the general-purpose agent to explore the structure...
[general-purpose] ✓ Glob **/*.ts
[general-purpose] ✓ Read src/daemon/session.ts
1 /**
2 * Session — wraps a single Claude Agent SDK query...
...
Claude
## Summary
The daemon module has 14 files. Key layers:
...
╭──────────────────────────────────────────────────────────────╮
│ ● test ▸● studio2 ᴀ⚡ 📌1 ● core │
│──────────────────────────────────────────────────────────────│
│ ⠋ Reasoning… 4s · Ctrl-X to interrupt │
│ acting as …/agent/codeoid-session-7838ee1d │
│ via general-purpose …/subagent/explorer-abc │
│──────────────────────────────────────────────────────────────│
│ ● connected · studio2 @ /Workspace/codeoid · working · mode: autonomous (42 actions left) │
│──────────────────────────────────────────────────────────────│
│ Enter to send · Ctrl-N new · Ctrl-G switch · ? help │
│ › _ │
╰──────────────────────────────────────────────────────────────╯
Keybindings:
| Key | Action |
|---|---|
Ctrl-N |
New session |
Ctrl-G |
Switch session (fuzzy) |
Ctrl-D |
Destroy focused session |
Ctrl-X |
Interrupt focused session |
Esc |
Clear input draft — or interrupt if input is empty and session is working |
Shift-Tab / Ctrl-M |
Cycle execution mode |
y / n |
Approve / deny pending tool (when input empty) |
? |
Show keybindings overlay |
Ctrl-C |
Quit the TUI (sessions keep running) |
Prompt editor:
| Key | Action |
|---|---|
Enter |
Send |
Alt-Enter / Ctrl-J / \↵ |
Insert newline |
Up / Down |
Cycle prompt history |
Ctrl-A / Ctrl-E |
Line start / end |
Ctrl-U / Ctrl-K |
Clear to start / end |
Ctrl-W |
Delete previous word |
@<path> |
Autocomplete a workspace file → attaches to send |
/ |
Slash-command hint overlay |
Slash commands (built-in):
| Command | Action |
|---|---|
/clear |
Clear the visible transcript (memory kept) |
/new |
New session (opens modal) |
/switch |
Switch session (opens picker) |
/destroy |
Destroy focused session |
/interrupt |
Interrupt focused session |
/mode [target] [budget] |
Set execution mode. /mode autonomous 100 = 100 write actions budget; /mode autonomous 0 = unbounded |
/pin <path> |
Pin a file — prepended to every turn |
/unpin <path> |
Unpin a file |
/context <path>… |
Attach files to the NEXT turn only |
/rotate |
Roll over the Claude Code backing session — fresh context, memory preserved |
/who |
Print the identity chain (user → agent → sub-agents) |
/help |
Show keybindings |
Pass-through: any /command that isn't a Codeoid built-in or a workspace command is forwarded verbatim to Claude Code — /compact, /agent, custom subcommands all work without Codeoid knowing about them.
Workspace commands: any .claude/commands/*.md in your workspace auto-loads as a slash command on session focus. The file body becomes the prompt template; $ARGUMENTS is substituted with whatever the user typed after the command name. Zero Codeoid changes when you add new commands.
Codeoid records every tool call, result, and reasoning block as an episode — stored verbatim, never summarized, retrievable via hybrid search. Claude gains three tools:
recall(query)— semantic + keyword search across all prior sessions in this workspacerecall_file(path)— check if a file was read recently; skip the re-read if cachedtimeline(limit)— chronological list of recent activity across sessions
Memory is workspace-scoped via git rev-parse --git-common-dir, so all git worktrees of the same repo share one workspace — feature branches inherit the main line's knowledge.
What's in the store:
~/.codeoid/memory.db — SQLite (episodes + FTS5 + embeddings)
~/.codeoid/models/ — BGE-small-en-v1.5 (~50MB, downloaded once)
Ranking:
| Signal | Weight | Purpose |
|---|---|---|
| Vector similarity (BGE-small cosine) | 0.55 | Semantic match |
| FTS5 BM25 | 0.25 | Exact-string / function-name match |
| Recency (48h half-life) | 0.12 | Prefer recent context |
| Path overlap | 0.08 | Files touched in common |
No external spend — embeddings run in-process via @xenova/transformers (WASM). Configurable to Ollama, OpenAI, Voyage via the Embedder interface.
Disable with CODEOID_MEMORY=0 if you don't want it.
# Start two parallel features on separate worktrees, both sharing memory
bun src/cli.ts new featA --worktree feat/parser --repo /Workspace/codeoid
bun src/cli.ts new featB --worktree feat/ui --repo /Workspace/codeoid
# In the TUI: Ctrl-G between them--worktree <branch> spawns <repo>.wt-<branch> via git worktree add and points the session at it. The branch is created if it doesn't exist. Idempotent — re-running with the same args reuses the existing worktree.
Because workspaceId anchors on the shared .git/common-dir, both sessions live in the same workspace for memory purposes. Session A's Read auth.ts is available to Session B's recall("auth flow").
Each session has a mode. Only codeoid's internal memory-recall tools are ever
pre-approved at the SDK layer — every real tool (Read/Grep/Glob/Write/
Edit/Bash/Agent) is gated by the mode below:
| Mode | Behavior |
|---|---|
guarded (default) |
Read / Grep / Glob / memory auto-approve; Write / Edit / Bash / Agent prompt. ≈ Claude Code's default mode. |
interactive |
Every tool call prompts for approval — including reads. |
autonomous |
Everything auto-approves (no prompts) until the write/exec budget is spent, then reverts to guarded. ≈ Claude Code's bypass mode. |
Cycle with Shift-Tab (or Ctrl-M on terminals that swallow shift-tab). Set explicitly with /mode autonomous 100 for a 100-action budget. /mode autonomous 0 for unbounded (use with caution).
The StatusBar shows the current mode + remaining budget; SessionTabs shows ᴀ⚡ badge when autonomous.
Three ways to add file context to a turn:
Inline @file mentions (TUI):
› what does @src/daemon/session.ts do?
Tab-completes from your workspace, resolves the path, attaches the file's content to the turn. Multiple @paths per message work.
/context (one-shot attachments):
/context src/foo.ts src/bar.ts
Sends a minimal "review these" prompt with the files attached.
/pin (persistent across turns):
/pin SPEC.md
/pin acceptance-criteria.md
Pinned files are re-read and prepended on every turn until you /unpin. Pins survive daemon restart (stored in session_pins SQLite table). The SessionRail shows 📌N for pinned count.
Web UI adds drag-drop and paste support: drop a file onto the chat area, it's read locally (up to 200 KB) and inlined as an attachment.
Size limits (enforced by the daemon):
- 100 KB per file, 500 KB total per turn
- Binary files (null-byte sniff) skipped with an inline error marker
- Missing files surface as
<file error="...">so Claude sees why a path didn't resolve
Every session gets a primary SPIFFE/WIMSE URI. Sub-agents (spawned via Claude's Agent tool) get their own attenuated identities. Every tool_call SessionMessage is stamped with the identity that made it — parent session OR sub-agent worker.
The TUI surfaces this:
-
WorkingIndicator shows the acting agent URI + any active sub-agents
-
Tool rows from sub-agents get a green
[name]prefix:[general-purpose] ✓ Read foo.ts -
/whoprints the full delegation chain:## Identity chain for studio2 You — user_xxx ↓ Session agent — spiffe://highflame.ai/personal/dev/agent/codeoid-session-7838ee1d ↓ ### Active sub-agents (1) - general-purpose (spiffe://highflame.ai/personal/dev/subagent/explorer-abc)
Why this matters: in audit/compliance terms, every tool call is cryptographically attributable to an exact delegated identity. Revoke the parent in ZeroID → the whole chain dies. Sub-agents get scope-attenuated tokens so they can't escalate.
Detach on laptop, attach from phone. The scrollback buffer (5000 entries / 20MB) replays what happened while you were away. Same session state, same memory, same pending approvals.
laptop$ codeoid attach oracle
> refactor the auth module
[agent working...]
Ctrl+C # detach — session keeps running
# Later, from your phone via Telegram:
/attach oracle
# → scrollback replays → continue the conversation
Three orthogonal layers, each opt-in, each carrying its weight:
Layer C — workspace memory index (on by default with memory enabled)
A compact markdown block auto-injected into every turn's system prompt. Contains:
- Fingerprint:
294 episodes across 8 sessions · last activity 8m ago - Hot files: top 10 by touch count, each with a
recall_file(path)nudge - Topic clusters: k-means (k ≤ 8) over episode embeddings, labeled with dominant directory + content terms (optional LLM re-labeling via Haiku when
ANTHROPIC_API_KEYis set) - Recent sessions: one-liner per session with its first user-turn summary
- Recall shortcuts: compact tool reference
Rebuilt on a hybrid trigger: ≥ 5 new episodes OR ≥ 60s elapsed with ≥ 1 new episode, debounced to max one regen per 15 s so the prompt cache stays warm. Toggle: CODEOID_WORKSPACE_INDEX=0 disables, CODEOID_MEMORY_CLUSTERS=1 enables the cluster section.
Layer B — CLI output compression (opt-in: CODEOID_COMPRESS=1)
A homegrown RTK-style compressor that routes Bash tool invocations through a daemon-local wrapper before Claude sees them:
PreToolUsehook rewritesBash({ command: "git diff HEAD~5" })→Bash({ command: "bun …/wrapper.ts --b64 …" })- Wrapper runs the real command, captures stdout, applies declarative rules (
git-diffcollapses long unchanged context,git-statuselides huge untracked-files lists,test-runnerdrops passing-test noise,ls/cat/find/grepsummarize by extension/dir/file), 60–90% reduction on shell-heavy turns - Stderr never compressed (error fidelity matters)
- Raw output lives in our verbatim memory store — Claude can
recall(query)to retrieve the original bytes if the compressed version dropped something it needs
Rule format is declarative TypeScript:
export const myRule: CompressionRule = {
name: "my-rule",
description: "…",
match: (cmd) => /^mything\b/.test(cmd),
compress: (stdout, ctx) => ({ compressed, originalBytes: ctx.rawBytes, ruleName: "my-rule" }),
};Drop new rules in src/daemon/compress/rules/ — first match wins, generic head+tail truncator is the last-resort fallback.
Layer D — auto-rotation of the backing session (opt-in: CODEOID_AUTO_ROTATE=1)
When context occupancy creeps toward Claude Code's compaction ceiling, Codeoid rolls the underlying Claude Code session to a fresh backing id while keeping codeoid's public session id stable. The user never notices — same tab, same scrollback, same memory.
Seed strategy B: task-anchor (current default):
- Capture the most recent user turn from memory
- Inject a
<rotation_context>block into the first post-rotation prompt with: workspace, rotation count, last user message verbatim, reminder to callrecall/recall_file/timelinefor prior detail - No summarization — full verbatim memory is one tool call away
Thresholds (all configurable):
| Threshold | Default | Behavior |
|---|---|---|
warnPct |
60% | (Reserved for UI nudging — currently unused) |
rotatePct |
80% | Auto-rotate when enabled: true + over minTurnsBeforeRotate |
hardRotatePct |
90% | Rotate even when enabled: false (safety net) |
minTurnsBeforeRotate |
3 | Prevent rotation on fresh sessions where seed would be empty |
Each rotation emits a scrollback info message, bumps the rotation counter (persisted in SQLite), and shows 🔄 N in the StatusBar. Manual trigger via /rotate slash command.
Claude Code's interactive CLI is turn-based — you wait for the response before sending the next message. The SDK underneath actually supports AsyncIterable<SDKUserMessage> streaming, which codeoid uses for mid-turn responsiveness.
When you hit Enter on an idle session: FIFO push, business as usual. When you hit Enter on a working session: codeoid auto-sets priority: "now" — the SDK aborts Claude's in-flight response and restarts with the new context included. You pay ~1-2 s for time-to-first-token on the restart; the alternative (priority: "next") gets two separate responses instead of one unified one.
Explicit control is exposed on the protocol — frontends can pass priority: "now" | "next" | "later" on any session.send message. Default is the smart auto-promotion above.
A live ⎆ N queued badge on the StatusBar shows queue depth. Turning-point feedback: the moment you queue a mid-turn message, an info row appears immediately so you know it was received, even though Claude's reply takes a second to start flowing.
Flip a session to autonomous mode and send it off:
/mode autonomous 50
› go finish the refactor; commit when tests pass
The session auto-approves up to 50 write/exec actions. Reads + greps + memory recall don't count against the budget (they're classified as safe). When the budget runs out, the session reverts to interactive — the next write/bash will prompt you.
Status bar shows live budget: autonomous (37 actions left). You can interrupt anytime with Ctrl-X.
A guard observes the session and may inject model-facing advice. It never vetoes a tool call, never rewrites arguments, and never appears in the tool list — anything that needs to stop a call belongs to the approval flow or the autonomous budget instead.
Repeat-tool guard (on by default) is the loop-breaker.
It counts runs of consecutive calls to the same tool with identical arguments, and at 3, 5, and 8 in a row it injects an escalating advisory telling the model to re-read the result it already has and either change approach or conclude.
This matters most where nobody is watching.
An unattended dispatch worker wedged on Read(same file) or Bash(same failing command) will otherwise burn its whole tool budget and report failure with no diagnosis; the budget caps the damage, the guard catches the cause while the turn can still recover.
Chains are tracked per emitting agent, so two subagents hammering the same tool in parallel are two independent runs rather than one interleaved chain that resets forever and never fires.
Argument comparison is order-insensitive (deep key-sort), so {a:1, b:2} and {b:2, a:1} are the same call.
Any inbound message — owner, background-task digest, or dispatch task — resets every chain, because the guard only claims "N identical calls with nothing else happening".
Invalid thresholds fail loud at construction rather than silently reverting to defaults — a guard that never fires because of a typo is worse than no guard. If the config is bad the session logs it and starts without the guard; an advisory plugin is never a reason to refuse a session.
The advisory arrives as injected context on the model's next request, wrapped in a <repeat_tool_notice> block and explicitly labelled as daemon-authored so it is never mistaken for owner input. The tool call that triggered it is unaffected — already recorded, already on its way to approval or execution.
Injection uses later priority, which merges into the running turn without starting a fresh query, so a reminder costs no extra turn. Backends without mid-turn injection get no reminders rather than a message arriving out of context.
Append-only. The advisory lands after the reusable request prefix and invalidates no prior cache entry. Cost is the advisory itself: ~60 tokens for the brief form, and up to argumentsPreviewChars more for the detailed form.
The Model Experience / KV Cache effect headings are a convention borrowed from DeepSeek Harness (see prior-art-deepseek-harness.md §4.9). Any feature that changes what the model sees should state both: what reaches the model, and whether it invalidates the prompt prefix. For a harness, prefix stability is cost.
Mobile-first SolidJS SPA at http://localhost:7400/ui/. Also works as a Telegram Mini App:
- Session switcher
- Approval buttons
- File browser + drag-drop attachments
- Markdown rendering for assistant replies
- Real-time thinking display
Set TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS (put them in
~/.codeoid/.env so they survive restarts),
then bun src/cli.ts start. Commands:
| Command | Action |
|---|---|
/auth <api_key> |
Authenticate with ZeroID |
/ls |
List sessions |
/new <name> <workdir> |
Create session |
/attach <name> |
Start receiving output |
/detach |
Stop receiving (session keeps running) |
/interrupt |
Interrupt running agent |
/destroy <name> |
Destroy session |
yes / no |
Approve / deny tool calls |
| (any text) | Send to attached session |
While a turn is running, a one-tap ⏹ Stop button appears on the chat —
the mobile equivalent of Esc / /interrupt.
Thinking content and sub-agent tool calls surface as separate italic messages. Streamed assistant text is buffered per message and flushed when Claude finishes (Telegram's rate limits make per-token streaming infeasible).
| Pattern | What it does |
|---|---|
| Retry with fallback model | Exponential backoff with jitter, 429/529/5xx categorization, falls back to cheaper model after 3 capacity failures. |
| Graceful shutdown | LIFO cleanup registry with 30s grace. SIGTERM / SIGINT / SIGHUP handlers drain sessions before closing the store. |
| Session resume | JSONL transcripts per session; user prompts written BEFORE API calls. On daemon restart, sessions rebuild from transcript; #hasQueried flag ensures Claude Code's own session store is reused via resume instead of re-creating. |
| Session limits | None by default — running many sessions in parallel is the point. rateLimit.maxSessionsPerUser / rateLimit.maxCreationsPerHour default to 0 (unlimited); set either for a shared multi-user daemon. Automated spawning is bounded at its source by dispatch.maxConcurrentWorkers. |
| Tool approval correlation | Each approval request has a unique approvalId; first response wins; multiple concurrent approvals supported. |
| Subprocess stderr capture | Claude Code subprocess stderr is piped into the daemon log so SDK-level failures are debuggable. |
| Keep-warm interrupt | Interrupt (Esc / ⏹ / Ctrl-X) stops the in-flight turn via the SDK's Query.interrupt() — it reaps the running tool but keeps the backing session alive, so your next message continues on the same context. No re-query(), no resume handshake. Claude Code parity. |
| Never-lose user messages | A sent message is persisted to the transcript the instant it's accepted — before attachment resolution, rotation, or the SDK call. A failure afterward surfaces a visible ⚠️ row (never a silent drop), and frontends render send rejections instead of swallowing them. |