Skip to content

feat: adopted-sessions lifecycle & polish — SessionEnd/Start, idle signal, web-managed denylist (Journey 3) - #22

Merged
PouyanJay merged 9 commits into
mainfrom
feat/adopted-sessions-lifecycle
Jul 1, 2026
Merged

feat: adopted-sessions lifecycle & polish — SessionEnd/Start, idle signal, web-managed denylist (Journey 3)#22
PouyanJay merged 9 commits into
mainfrom
feat/adopted-sessions-lifecycle

Conversation

@PouyanJay

Copy link
Copy Markdown
Owner

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 row done.
  • SessionStartchat-only sessions adopt before their first tool, with a cwd-basename title so rows read sensibly.
  • Notificationagent.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 dismissible SessionNotice banner.
  • Web-managed adoption policy — a Settings panel to toggle adoption + edit a denylist of excluded projects. The adopt.config/adopt.state messages 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.
  • Installer + doctor — the installer now registers the full lifecycle hook set (PreToolUse + SessionStart + SessionEnd + Notification); telecode doctor gains an advisory adoption check.

Tests & gates

  • 609 tests pass: protocol 88 · daemon 166 · relay 127 (real Postgres) · web 228. typecheck · eslint · prettier · svelte-check (0/0) all green.
  • Daemon integration drives the real hook-bridge → socket → daemon → relay path; deterministic negative assertions (no-announce / no-emit) via the "first-of-type" frame trick; E2E ciphertext tests prove agent.question and adopt.config/adopt.state reach 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

  • Adoption opt-in stays TELECODE_ADOPT (env) + telecode hooks install (laptop); the web toggle/denylist is the runtime policy.
  • Stop (which hands us last_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.
  • The daemon needs a @telecode/cli republish for adoption to work end-to-end on the live deployment.

… 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.
@PouyanJay
PouyanJay merged commit 45ee040 into main Jul 1, 2026
2 checks passed
@PouyanJay
PouyanJay deleted the feat/adopted-sessions-lifecycle branch July 1, 2026 07:18
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