feat: adopted-sessions lifecycle & polish — SessionEnd/Start, idle signal, web-managed denylist (Journey 3) - #22
Merged
Conversation
… walking skeleton)
Adopted sessions never received a session.ended and lingered as running
forever. Wire the SessionEnd lifecycle hook through end-to-end:
- hookEventSchema: add the defensively-optional lifecycle fields captured
empirically (source for SessionStart, reason for SessionEnd, message
for Notification).
- daemon handleHookEvent: dispatch SessionEnd early — end the adopted
session only if already tracked (telecodeIdFor), never force-adopt an
unknown session just to end it (no phantom row). Final transcript
mirror to capture trailing lines, then setStatus(done) + session.ended
(idempotent; E2E key established first). Non-PreToolUse events return {}.
Tests: daemon integration (SessionEnd ends a tracked session; no-op +
no phantom announce for an unknown one) + a real relay/Postgres test
asserting the external row is marked done with ended_at set. The walking
skeleton traverses hook -> daemon -> relay -> DB.
…rney 3, Task 2) A chat-only Claude Code session (one that never calls a tool) was invisible to telecode because adoption only happened on the first PreToolUse. handleHookEvent already adopts on any non-SessionEnd event, so SessionStart now adopts eagerly once its hook is installed (Task 7). Also derive the registry row title from the project directory basename (e.g. .../myrepo -> 'myrepo') so a session adopted before any user prompt reads sensibly in the dashboard (resolves the plan's title- derivation open question). Applies to all adoptions; empty/'/' cwd falls back to no title. Daemon test covers SessionStart adoption + the derived title. 147 daemon tests pass.
…al (Journey 3, Task 3)
When an adopted Claude Code session goes idle (Notification hook), tell
the dashboard it needs a look — a non-blocking cue, no answer required:
- protocol: new agent.notice (daemon -> web) message + agentNoticePayloadSchema
({ message }), carrying Claude Code's own Notification text. Transient
(not cached for reopen).
- daemon: a Notification branch in handleHookEvent emits agent.notice ONLY
for an already-tracked session (never force-adopt on a stray notification)
and SKIPS it while a gate/question is already showing (a permission-prompt
notification is redundant then). E2E-encrypted via sendForSession.
- relay: forwards agent.notice generically and web-pushes it (needs
attention while away) WITHOUT changing the persisted status (a notice is
not a gate).
Notification is interactive-only (it doesn't fire headless), so this is
unit/integration-tested deterministically (daemon emit + skip-while-gating
+ ignore-unknown; relay forward keeps status running) and will be manually
verified in VS Code. protocol 85 + daemon 149 + relay 127 tests pass.
…y 3, Task 4) Consume agent.notice in the web as a transient, dismissible 'needs attention' cue (e.g. an adopted session went idle waiting for input): - session reducer: a session-level `notice` (Claude Code's notification text), set on agent.notice and cleared the moment any other frame arrives (the session moved on) or it ends; reopen carries no live notice. Reducer tests cover set / clear-on-next-frame / clear-on-end / ignore-invalid. - SessionNotice.svelte (enterprise-ui): an amber-accented, dismissible banner with role=status (announces the live cue), shown atop the session stream; the dismissed message stays hidden until a new notice. Scope note: the idle notice is transient, so it is surfaced in the session view + the web push (Task 3), not as a flickering dashboard dot — the durable dashboard 'needs you' cue stays awaiting_input. web 227 tests pass; svelte-check + eslint clean.
…rney 3, Task 5) Let the per-machine adoption policy (enabled + denylist) be managed from the web and enforced by the daemon at runtime, end-to-end encrypted so the relay never sees repo paths (invariant #5): - protocol: adopt.config (web -> daemon; { set? } — set persists, omit = read) + adopt.state (daemon -> web; current { enabled, denylist }) + adoptSettingsSchema. Both session-less, so the relay forwards them generically, payload-blind (no relay change). - daemon cipher: openFromBrowser / sealToBrowser — the same ECDH device-shared-key seam as decryptLaunch / keyDelivery, session-less. - adopt-config.ts store (~/.telecode/adopt-config.json, 0600, zod, adopt- all default on missing/corrupt) + is-adoption-allowed.ts pure matcher (enabled AND cwd not under any denylist path-prefix; siblings sharing a prefix are NOT blocked). - daemon: load the policy on start; handleAdoptConfig opens the sealed config, persists+applies a set, and replies adopt.state sealed to the requesting browser (logs the shape, never the paths); a runtime gate in handleHookEvent leaves a disabled/denied session entirely to Claude Code's local flow (no adopt, no mirror, no gate). Tests: adopt-config store + matcher units; daemon integration (SET persists + replies; GET replies; denylist blocks adoption; disabled blocks; re-enable adopts). 161 daemon + 88 protocol tests pass. (Web store/client + Settings UI = Task 6; E2E adopt.config ciphertext = Task 8.)
The web side of the web-managed adoption policy — read/write the daemon's
{ enabled, denylist } over the sealed channel:
- browser cipher: sealToDaemon / openFromDaemon (the same device
shared-key seam as sealLaunch / receiveKey), session-less.
- relay-client: sendAdoptConfig(set?) sends a box-sealed adopt.config
(GET when set is omitted) announcing our pubkey; an inbound adopt.state
is opened with the device key and surfaced via onAdoptState.
- session-store: an adoptState store + requestAdoptConfig / setAdoptConfig;
reset on disconnect. Store test covers GET/SET forwarding + adopt.state.
- Settings: a new 'Adopted sessions' panel — an enabled Switch + a
denylist editor (add path / remove per row), reflecting the daemon's
confirmed policy (verification-gated), with loading / not-connected /
empty states and a guide to 'telecode hooks install'.
web 228 tests pass; svelte-check + eslint clean. (E2E sealed round-trip =
Task 8.)
…y 3, Task 7) - hooks installer now registers the full adoption hook set — PreToolUse + SessionStart + SessionEnd + Notification (Stop stays reserved for the Journey-4 free-form detector) — so adopting/ending a session and its idle cues all fire. Idempotent/reversible as before. - telecode doctor gains an advisory 'Adopted sessions' check (never fails): off when TELECODE_ADOPT=0, warn (with the install hint) when hooks aren't installed, pass listing the registered events otherwise. Wired via a readHooksStatus dep; the hooks-status test now asserts the four installed events. 162 daemon tests pass.
…3, Task 8) Prove invariant #5 for the adoption policy: a browser box-seals adopt.config to the daemon's key; the daemon opens it and replies adopt.state as opaque ciphertext (non-empty nonce, string payload) which only the holder of the device shared key can open — so the denylist's repo paths never reach the relay in cleartext. Completes the variant coverage (matcher / Notification / SessionEnd / config set-get-deny-disable already covered).
From the 3-agent review (typescript + test + clean-code), all PASSED 0-blocking: - Extract handleSessionEndHook / handleNotificationHook from the grown handleHookEvent (now a thin dispatcher); drop an inline what-comment; clarify the Notification guard is about gate state, not rate-limiting. - Drop the 'state as unknown' cast in handleAdoptConfig via a typed fields var. - Named HookStatus type (doctor) replacing the duplicated inline shape. - Rename single-letter 's' -> 'state' in the Settings adoption handlers. - Comments: CACHEABLE_TYPES excludes agent.notice intentionally (transient); adoptStatePayloadSchema alias is deliberate; the adopt.state catch also swallows a version-skew schema mismatch (acceptable rare edge). - Tests: split the 3-in-one doctor adoption test into three; extract assertReasonContainsAll to keep the loop out of the it.each body; zod-parse the new payload reads (session.adopted title, agent.notice) instead of casts; add a valid-JSON-but-invalid-schema config case. Deferred (documented): pre-existing function sizes (handleFrame / routeFrom* / applyEnvelope — this PR adds only thin delegating arms), the injected-logger fallback + handleFrame exhaustiveness (pre-existing patterns), and adopt-config.ts's 3-export IO layer (the claude-settings.ts precedent). protocol 88 + daemon 166 + relay 127 + web 228 tests pass; all gates green.
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 3 — adopted-sessions lifecycle & polish (Phase 4)
Builds on Journeys 1 (monitor + approve/deny) and 2 (answer questions). This journey makes adopted Claude Code sessions complete — they start, end, signal when they need you, and you control which projects telecode may touch.
What's in it
SessionEnd— adopted sessions now end (they lingered as "running" forever); only-if-tracked, idempotent; the real relay/Postgres marks the external rowdone.SessionStart— chat-only sessions adopt before their first tool, with a cwd-basename title so rows read sensibly.Notification→agent.notice— a non-blocking "needs attention / went idle" cue (daemon emits for tracked sessions, skips while a gate is showing; relay forwards + web-pushes, no status change). Surfaced as a dismissibleSessionNoticebanner.adopt.config/adopt.statemessages are box-sealed to the daemon's key (reusing the existing ECDH seam — no new crypto), so the relay never sees repo paths (invariant Professional README + structured docs (with dedicated E2E & pairing guides) #5). Persisted to~/.telecode/adopt-config.json(0600). A runtime gate leaves a disabled/denied repo entirely to Claude Code's own local flow.telecode doctorgains an advisory adoption check.Tests & gates
agent.questionandadopt.config/adopt.statereach the relay only as ciphertext.Review
3-agent code review (typescript + test + clean-code) — all PASSED, 0 blocking, confirming invariant #5 for denylist paths, the fail-closed/fail-open gate, zod at every boundary, and no path-logging. Findings fixed in
3fea456.Notes
TELECODE_ADOPT(env) +telecode hooks install(laptop); the web toggle/denylist is the runtime policy.Stop(which hands uslast_assistant_message) is reserved for Journey 4 (Phase 4.5 / Tier 4 — free-form handover & resume, spike-first). Phase 5 (variant/hardening) is the closeout after Tier 4.@telecode/clirepublish for adoption to work end-to-end on the live deployment.