feat: adopt externally-started Claude Code sessions (Journey 1 — monitor + approve/deny) - #20
Merged
Merged
Conversation
Add the protocol surface for adopting externally-started Claude Code
sessions: a `sessionOriginSchema` ('launched' | 'external') that mirrors
the new `sessions.origin` registry column, and a `session.adopted`
message + `sessionAdoptedPayloadSchema` the daemon uses to announce a
discovered external session so the relay mints an origin='external' row.
clientRef carries the daemon's Claude session_id correlation (echoed back
with the minted telecode id, mirroring session.launch → session.started).
Tests-first: origin enum + adopted payload validation, and the new
message type round-trips through the envelope. 71 protocol tests pass.
…urney 1, Task 2)
Add the `origin` column to the sessions registry ('launched' | 'external',
default 'launched') so the relay can mark sessions the daemon adopts from
the user's own Claude Code runs. Drizzle schema + generated migration
0005_session_origin (+ snapshot/journal). Promote the origin values to a
shared `SESSION_ORIGINS` const in @telecode/protocol so the Drizzle enum
and the zod schema stay in lockstep.
RLS: sessions already has row-scoped policies + telecode_app grants from
0000_init; those cover all columns, so a column add needs no policy change.
Default keeps every existing row and browser-initiated launch unchanged.
…, Task 3) Handle inbound `session.adopted` from a daemon: mint an origin='external' registry row (status 'running' — an adopted session is already underway), ACK the daemon with the minted session_id + its clientRef so it can pair its hook events, and broadcast the adopted session to the watching browsers. The mirror image of a browser session.launch, but daemon-driven. Registry: createSession now takes origin/title/cwd and derives the starting status from origin; SessionSummary + listByUser + /me/sessions surface origin so the dashboard can mark adopted sessions. Invalid announces (no clientRef) are dropped. Integration test (real relay + PG) covers the mint→ack→broadcast round-trip and the drop path.
…ask 4)
Add the local transport the `telecode hook` bridge uses to reach the
daemon: a same-uid Unix domain socket (run dir 0700, socket 0600 — no TCP
port, preserving the outbound-only invariant). The bridge writes one
hook-event JSON and half-closes; the server parses it at the trust
boundary (zod), runs an injected handler, and writes the response back
(allowHalfOpen so the reply survives the client FIN).
Fail-closed by construction: a malformed event or a throwing handler
returns `{}` (no decision), so Claude Code falls back to its own
permission flow — never an auto-allow. hook-event.ts is the Claude
Code↔telecode hook contract (incl. tool_use_id) + the PreToolUse output
builder. Transport is injected/substitutable; 5 unit tests cover parse,
0600 perms, both fail-closed paths, and socket cleanup.
Map a Claude Code session_id → the telecode session id the relay mints. First hook event for an unknown Claude session announces it (the Claude id is the clientRef) and awaits the relay's session.adopted ACK; later events reuse the cached id, and concurrent first-events dedupe into one announce. A missing ACK rejects after a timeout so the caller fail-closes; a late ACK still records the mapping. Pure + DI (announce injected) — 5 unit tests; the daemon.ts wiring lands in T8 with the gate.
Read the hook-provided JSONL transcript of an adopted session and map it to telecode transcript entries (AD-1: the one sanctioned read of ~/.claude/projects — hook-supplied path only, adopted sessions only, never scraping). user prompts → user entries, assistant text → message, assistant tool_use → tool; thinking/images/tool-results and non-conversation records are skipped. Parsing is DEFENSIVE — a malformed or half-written line is skipped, never thrown. createTranscriptMirror tails the file incrementally (offset-tracked, leaves a partial trailing line unconsumed). 7 unit tests incl. golden fixtures + malformed input.
Add the operator-facing surface for adoption:
- `telecode hook` — the bridge Claude Code spawns per hook event: pipes
the hook JSON (stdin) → the daemon's Unix socket → the decision (stdout).
Fail-closed (AD-2): a dead/unreachable daemon yields `{}` so Claude falls
back to its local prompt — never an auto-allow. Only the connect is
bounded; the response wait is unbounded (the hook's own timeout bounds it).
- `telecode hooks install|uninstall|status` — opt in/out by editing
~/.claude/settings.json: idempotent (no duplicate telecode entries),
reversible (removes exactly telecode's hooks, preserves the user's), and
transparent (pretty JSON). Long hook timeout (AD-3). Registers PreToolUse
for now; more events follow in Journey 3.
Both are tested as pure functions (bridge against the real T4 socket;
installer against a temp settings file — never the global config). main.ts
wires the two subcommands. 25 adopt-module tests pass.
…y 1, Task 8)
Integrate the five adopt modules into the daemon (the security-critical
join). When `options.adopt` is set the daemon listens on the hook Unix
socket and, for each hook event: adopts the session (announces
`session.adopted`, awaits the relay's minted id, pairs it on the ACK in
handleFrame), mirrors its transcript, and for a PreToolUse routes the tool
through the EXISTING gate — read-only auto-allows via classifyTool, a
consequential tool blocks on the browser's `permission.decision`. Adopted
sessions reuse `requestPermission`/`sendForSession`/the transcript record
via a synthetic cleartext source envelope. FAIL-CLOSED (AD-2): adoption
failure returns `ask`; the socket returns `{}` on any error — never an
auto-allow. main.ts enables it by default (off via TELECODE_ADOPT=0);
listening is harmless until `telecode hooks install`.
End-to-end test (real daemon + socket + fake relay): announce + auto-allow
read-only, gate a consequential tool, honor allow + deny. All 126 daemon
tests pass.
…adlock (Journey 1, Task 8) Two corrections to the T8 wiring: 1. Invariant #5 — adopted sessions now run end-to-end encrypted: the daemon establishes a per-session content key on adoption (gated by cipher.enabled, as every paired daemon is), so the relay forwards only ciphertext for adopted-session frames, not plaintext. The key is delivered to the browser on session.subscribe (the existing reconnect path). Cleartext only on a pre-E2E daemon (tests). 2. stop() deadlock — stop() awaited hookSocket.stop() (which waited on the in-flight bridge connection) BEFORE settling pendingPermissions (what unblocks it). Reorder: settle the gates first, then stop the socket; and the hook socket now force-closes lingering connections so a blocked gate can never hang shutdown. Adds an E2E test asserting the relay sees a non-empty nonce + ciphertext (not the cleartext gate payload). 127 daemon tests pass, no unhandled errors.
Surface a session's origin through the web: relay-api carries `origin` from /me/sessions (defaulting to 'launched' for back-compat), the dashboard rows thread it (registry rows keep their origin; sessions launched this visit are 'launched'), and SessionRow shows an "on device" Pill for adopted (external) sessions — so the operator can tell a session telecode adopted from their own Claude Code run apart from one launched here. svelte-check clean, 217 web tests pass.
All four review agents passed with zero blocking findings; this addresses the Important items: - security: guard against a forged session.adopted corrupting the daemon's id-map — AdoptedSessionManager.isPending() check before resolveAck (via a new handleAdoptedAck), plus a relay early-return so a session.adopted with a session_id can't fall through to the browser broadcast. - zod at the trust boundary: hooks-install reads ~/.claude/settings.json via claudeSettingsSchema.safeParse (was an unchecked `as` cast) — a corrupt hooks field degrades to "no telecode hooks" instead of crashing. - logger injection: hook-socket / adopted-sessions / transcript-mirror now require an injected logger (no pino() root logger in library code). - deterministic tests: vi.useFakeTimers for the ack-timeout test; vi.waitUntil on relayLogs instead of a 200ms wall-clock wait. - input bounds: .max() caps on the session.adopted clientRef/title/cwd. - polish: isAdoptEnabled/isAdopted/isAwaiting naming, userRow/deviceRow, mirrorTranscript drops a derivable param, corrected adoptedSource doc, quoted bin path, log.warn on a malformed ACK. Deferred with an Architecture Decision (one-export splits, an origin CHECK constraint, polish suggestions) — see .memory tracking. Gates green: typecheck, lint, format, 537 tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Journey 1 of adopted sessions: telecode now sees and gates Claude Code sessions the user starts themselves (terminal
claudeor the IDE sidebar) — without launching through telecode — via the official Claude Code hooks API.Phase 0 was a hard-gate spike (verified empirically: command hooks fire + block, the
PreToolUseinput carriestool_use_id, the transcript JSONL parses, and deny-feedback works) — GO. This PR is the walking skeleton + approve/deny.How it works
What's in it (10 tasks)
session.adoptedmessage + payload,SESSION_ORIGINS/sessionOriginSchema.sessions.origincolumn (launched/external, defaultlaunched) + Drizzle migration; RLS unchanged (row-scoped policies cover it).telecode hookbridge, andtelecode hooks install/uninstall/status(edits~/.claude/settings.jsonidempotently + reversibly). All wired through the existingpendingPermissionsgate.Safety / invariants
ask/{}— Claude falls back to its local prompt, never an auto-allow.telecode hooks install; kill switchTELECODE_ADOPT=0), reversible, hooks-only control plane (no CLI/terminal scraping).Out of scope (later journeys)
Answering Claude's questions remotely (the deny-feedback path is Journey 2); lifecycle hooks (
Notification/SessionEnd), per-project allowlist, and polish are Journey 3.Review & gates
66456cd(incl. a forged-ACK guard, zod at the settings boundary, injected loggers, deterministic tests); a few structural/polish items deferred with a documented Architecture Decision.pnpm typecheck(5/5),pnpm lint,pnpm format:check— clean. 537 tests pass (protocol 71, daemon 127, web 217, relay 122), including an end-to-end daemon↔socket↔relay adopt test and a real-Postgres relay registration test.