Skip to content

Adopted sessions: free-form handover & resume (Journey 4 / Tier 4) - #23

Merged
PouyanJay merged 9 commits into
mainfrom
feat/adopted-sessions-freeform-resume
Jul 3, 2026
Merged

Adopted sessions: free-form handover & resume (Journey 4 / Tier 4)#23
PouyanJay merged 9 commits into
mainfrom
feat/adopted-sessions-freeform-resume

Conversation

@PouyanJay

Copy link
Copy Markdown
Owner

What

When an adopted Claude Code session (one you started yourself in the terminal / IDE, that telecode monitors via hooks) ends a turn asking a free-form question — prose, no tool call, so there's no PreToolUse gate to answer through — telecode now offers to take it over: a "continue here" card surfaces the question + a summary; on your answer it launches a forked, telecode-owned continuation that resumes the same conversation with your answer as the next turn. The adopted session is marked handed-off and linked to its continuation.

This closes the last gap in adopted-session steering: previously a free-form question was a dead "answer at your device" wall.

How it works

  • Detect — a Stop hook (added to the installed set) fires at turn end; a dismissible heuristic on last_assistant_message decides if it looks like a question. Non-blocking (the idle external process is never held); honors the current adoption policy.
  • Capture — a deterministic summary is extracted from the mirrored transcript (no extra model call).
  • Offeragent.handover (daemon → web, ciphertext to the relay) surfaces the actionable card.
  • Take overhandover.answer → the daemon launches a continuation via resume + forkSession (spike-proven: full context, a new session id + its own transcript, so it never contends with the still-live external process), registered with the relay via session.chained and linked to the parent by a new parent_session_id column.
  • Fallbacks — a failed resume → a fresh launch seeded with the handover context; daemon offline → the honest "answer at your device" note.

The approval gate (canUseTool) stays fully in force on the continuation; the relay only ever forwards ciphertext + routing metadata.

Scope

Spike-gated (SDK resume-of-an-externally-created-conversation + forkSession + the Stop detector all empirically confirmed before building). Tasks T1–T8: walking skeleton → fork-resume + fallbacks → detector → summary → card (enterprise-ui, all states incl. offline) → parent↔child link + E2E-ciphertext test → installer Stop hook + doctor → variant coverage + Phase 5 hardening. Folds in the Phase 5 variant matrix.

Tests & gates

  • protocol 101 · daemon 193 · web 237 · relay 99 (+2 skip) · svelte-check 0 · typecheck 5/5 · lint · format
  • Playwright e2e 11/11
  • New DB migration 0006_session_parent_id (nullable self-FK, ON DELETE SET NULL; RLS row-scoped policies cover it)

Review

Multi-agent codereview (typescript · test · coding-style · supabase-security) — all passed, 0 blocking — plus the enterprise-ui self-review on the Svelte. Invariants #4 (gate), #5 (ciphertext), non-blocking Stop, and fail-soft resume all confirmed. Findings addressed in the final commit. One pre-existing flaky relay test (variants.test.ts, unrelated to this branch) noted.

PouyanJay added 9 commits July 2, 2026 18:57
…4, Task 1)

Adopted sessions that end a turn on a free-form question can now be taken over
remotely: telecode offers a handover and, on the user's answer, forks-and-resumes
the conversation as a telecode-owned continuation linked to the adopted parent.

- protocol: agent.handover / handover.answer / session.chained wire types + zod
  schemas + a `handover` history-entry kind
- db: 0006 migration adds sessions.parent_session_id (self-FK, set null) + index
- relay: daemon-initiated session.chained registration (origin=launched + parent
  link), agent.handover awaiting-input routing + cache, parent_session_id on
  /me/sessions and SessionSummary
- daemon: Stop hook -> non-blocking agent.handover offer (free-form heuristic,
  re-entrancy guard); handover.answer -> fork-resume-and-chain (resume the adopted
  session_id with forkSession, mark parent handed-off); forkSession threaded through
  the adapter to query()
- web: reducer `handover` kind + states, HandoverCard, answerHandover store/client

Integration tests span hook -> daemon -> relay -> fork-resume and the relay's
chained registration. Gates green: typecheck 5/5, lint, format, daemon 167,
relay 99 (+2 skip), web 233, protocol 98.
… 4, Task 2)

When telecode takes over a free-form handover it resumes the adopted conversation
with forkSession (full context, new id, own transcript). If that resume fails —
the SDK can't pick up an externally-created conversation — the continuation now
falls back to a fresh launch seeded with the handover context so the user's answer
is never dropped.

- daemon: runTurn gains resumeFallbackPrompt; a failed resume (non-abort) re-runs
  as a fresh, unforked launch seeded via buildHandoverFallbackPrompt(summary,
  question, answer)
- adopt/handover-fallback-prompt.ts: the seeded prompt (pure, unit-tested)
- claude-agent-adapter.test.ts: mocks query() to lock that forkSession reaches the
  SDK only when resuming (an ordinary turn never forks)
- daemon.handover.test.ts: proves the fallback — a resume-throwing adapter still
  completes the child via a fresh seeded launch (adapter called twice)

Gates green: daemon 174, typecheck 5/5, lint, format.
…k (Journey 4, Task 3)

The Stop hook fires on every turn end, so the handover detector now uses a real
heuristic to decide when a turn is asking the user something, and honors the
current adoption policy before offering (a handover launches a new session).

- adopt/free-form-question.ts: strips fenced code blocks, matches a trailing "?"
  (through closing quotes/emphasis/brackets) or a short list of high-precision
  solicitation cues, and bounds length; dismissible, so it favours precision
- daemon: handleStopHook re-checks isAdoptionAllowed(cwd) — a repo denylisted (or
  adoption disabled) after adoption gets no offer, even though it was tracked
- tests: 8 heuristic unit tests; 3 daemon integration tests (non-question,
  stop_hook_active re-entrancy, denylist mid-session all produce no offer)

Gates green: daemon 185, typecheck 5/5, lint, format.
…urney 4, Task 4)

The "continue here" card now shows a concise summary of what the adopted session
was doing, extracted deterministically from its mirrored transcript — no extra
model call (fast, private, zero cost).

- adopt/handover-summary.ts: buildHandoverSummary keeps recent user/assistant text
  turns (skips tool calls and gates), collapses whitespace, bounds per-entry and
  total length, and yields '' when there is nothing to summarize
- daemon: handleStopHook is now async — it re-mirrors the transcript before
  summarizing (a Stop fires after a turn no PreToolUse mirrored) and establishes
  the session cipher first so the mirror and the offer encrypt consistently
- tests: 5 summary unit tests; a daemon integration test writes a JSONL transcript
  and asserts the offer carries the extracted context

Gates green: daemon 191, typecheck 5/5, lint, format.
…rney 4, Task 5)

The free-form "continue here" card now degrades honestly when the device is
offline: instead of an actionable form that would fail, it shows an "answer at
your device" note and returns to actionable when the daemon reconnects.

- HandoverCard: new `offline` prop → pending offer renders the answer-at-device
  fallback; verified all states (pending / offline / submitting / submitted /
  closed), tokens-only, focus-visible, aria, reduced-motion, 16px textarea
- Transcript + session page thread `offline` from session.status==='offline_paused'

Gates green: svelte-check 0, web 233, lint, format.
… 4, Task 6)

The forked continuation is now navigably linked to the adopted session it
continues, and the offer's payload is proven ciphertext to the relay.

- daemon test: agent.handover reaches the relay as ciphertext (non-empty nonce,
  string payload) on a keypair daemon — the question + summary never go cleartext
  (invariant #5)
- web: reducer gains handover.childSessionId + SessionState.parentSessionId +
  linkHandoverChild; the store handles session.chained channel-wide to link parent
  and child across sessions; relay-api surfaces parent_session_id from /me/sessions
- HandoverCard: "View the continuation →" link once taken over; the session detail
  page shows "← Continued from an adopted session" for a continuation
- tests: reducer link + store cross-session link

Gates green: daemon 192, web 236, svelte-check 0, typecheck 5/5, lint, format.
…4, Task 7)

Adoption now registers a Stop hook so the daemon can detect a free-form question
at turn end and offer to take it over.

- hooks-install: Stop added to the telecode hook set (now PreToolUse, SessionStart,
  SessionEnd, Notification, Stop); still idempotent and reversible, and a user's own
  Stop hook is preserved alongside telecode's
- doctor lists the installed events dynamically, so `telecode doctor` now reports
  Stop as part of the adoption check
- tests: install asserts the five-event set + user-Stop preservation; doctor asserts
  Stop appears in the adoption detail

Gates green: daemon 192, typecheck 5/5, lint, format.
- daemon: a handover is not offered while a permission gate is already pending
  (never stacks an offer on an active gate)
- web: session.history backfills all three decision kinds (permission + question +
  handover) coexisting in one adopted transcript, each replaying correctly

Full suite green: protocol 98, daemon 193, web 237, relay 99 (+2 skip); Playwright
e2e 11/11; typecheck 5/5, lint, format.
Multi-agent review passed with no blocking issues; applying the important + cheap
findings.

- daemon: extract handleHandoverAnswer from the handleFrame case and guard the
  fire-and-forget launch with .catch() (no silently-swallowed errors); collapse
  runTurn's 6 positional params into a TurnOptions object; wrap the Stop-hook
  transcript mirror in try/catch (fail-closed with a clear log); clear
  pendingHandovers/pendingChainRegistrations on stop(); fix an orphaned JSDoc from
  the handler reorder; name the title-preview magic number
- protocol: tighten sessionChainedPayloadSchema.parentSessionId to .uuid()
- web: name the session-id display-length constant
- schema: document the parent_session_id reverse DDL (the drizzle-generated
  migration is left byte-stable so the migrator content-hash stays valid)
- tests: it.each for the message-type recognition; guard+typed assertion for the
  parent<->child link; non-null after vi.waitFor; uuid ids

Gates green: protocol 101, daemon 193, web 237, relay 99 (+2 skip), svelte-check 0,
typecheck 5/5, lint, format; Playwright e2e 11/11.
@PouyanJay
PouyanJay merged commit 491d80c into main Jul 3, 2026
2 checks passed
@PouyanJay
PouyanJay deleted the feat/adopted-sessions-freeform-resume branch July 3, 2026 16:15
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