Skip to content

feat: adopt externally-started Claude Code sessions (Journey 1 — monitor + approve/deny) - #20

Merged
PouyanJay merged 11 commits into
mainfrom
feat/adopted-sessions-skeleton
Jun 30, 2026
Merged

feat: adopt externally-started Claude Code sessions (Journey 1 — monitor + approve/deny)#20
PouyanJay merged 11 commits into
mainfrom
feat/adopted-sessions-skeleton

Conversation

@PouyanJay

Copy link
Copy Markdown
Owner

Journey 1 of adopted sessions: telecode now sees and gates Claude Code sessions the user starts themselves (terminal claude or 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 PreToolUse input carries tool_use_id, the transcript JSONL parses, and deny-feedback works) — GO. This PR is the walking skeleton + approve/deny.

How it works

claude (terminal/IDE) ──PreToolUse hook──▶ `telecode hook` bridge
   └─ Unix socket (~/.telecode/run/hook.sock, 0600, same-uid) ─▶ daemon
        ├─ announces session.adopted ─▶ relay mints an origin='external' row ─▶ dashboard
        ├─ mirrors the transcript (hook-provided transcript_path)
        └─ routes consequential tools through the EXISTING approval gate ─▶ browser allow/deny

What's in it (10 tasks)

  • Protocolsession.adopted message + payload, SESSION_ORIGINS/sessionOriginSchema.
  • DBsessions.origin column (launched/external, default launched) + Drizzle migration; RLS unchanged (row-scoped policies cover it).
  • Relay — daemon-initiated registration (mint row, ACK the daemon with the minted id, broadcast to browsers).
  • Daemon — a Unix-socket hook server, the adopted-session id manager, a defensive transcript mirror, the telecode hook bridge, and telecode hooks install/uninstall/status (edits ~/.claude/settings.json idempotently + reversibly). All wired through the existing pendingPermissions gate.
  • Web — adopted sessions appear with an "on device" marker.

Safety / invariants

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

  • Four review agents (TypeScript, tests, Supabase/DB, clean-code) + enterprise-ui self-review — all PASSED, 0 blocking. Important findings addressed in 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.

PouyanJay added 11 commits June 30, 2026 10:10
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.
@PouyanJay
PouyanJay merged commit beea1e8 into main Jun 30, 2026
2 checks passed
@PouyanJay
PouyanJay deleted the feat/adopted-sessions-skeleton branch June 30, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant