feat: answer adopted-session questions via deny-feedback (Journey 2) - #21
Merged
Conversation
…ey 2, Task 1) Add the adopted-session question messages for Phase 3 (answer Claude's multiple-choice questions remotely via deny-feedback): - agent.question (daemon -> web): mirrors the captured AskUserQuestion tool_input (questions[] with header, multiSelect, options[label, description]); description optional for version-drift safety; bounded caps. No allowsOther flag - Claude Code always implicitly allows 'Other', so it is carried only on the answer as free text. - question.answer (web -> daemon): one entry per question with selectedLabels[] and/or otherText; refined to reject an empty answer. Both added to MESSAGE_TYPES and exported from the package entrypoint. 11 new protocol tests (82 total pass).
Resolve the deferred Journey-1 review finding by splitting the cohesive multi-export adopt modules so each file has a single public export: - hook-event.ts -> keep the input schema; move preToolUseOutput + HookPermissionDecision to pretooluse-output.ts (input vs output). - transcript-mirror.ts -> keep the reader/factory; move the defensive JSONL parser transcriptEntriesFrom to transcript-parse.ts. - hooks-install.ts -> split into telecode-hook-command.ts (recognizer), claude-settings.ts (settings.json read/write/strip substrate), hooks-install.ts (installHooks), hooks-uninstall.ts (uninstallHooks), hooks-status.ts (readHooksStatus). TELECODE_HOOK_EVENTS made private. Pure refactor: importers (daemon.ts, main.ts) and tests updated; the hooks test is renamed hooks-settings.test.ts (it covers the whole lifecycle). All 127 daemon tests pass; typecheck/lint/format clean.
…rney 2, Task 3) Intercept AskUserQuestion at the PreToolUse hook for an adopted session and answer it remotely (Phase 3, Tier 3): - handleHookEvent routes tool_name === 'AskUserQuestion' to a question path BEFORE the permission gate: parse the tool_input defensively (questionsFromToolInput), forward a structured agent.question to the browser, block on the new pendingQuestions map until question.answer, then return permissionDecision:deny carrying the user's pick framed as a relayed user answer (buildQuestionDenyReason, AD-4/AD-J2-3). - New handleFrame case for question.answer resolves the pending question, records the answer on the question transcript entry, and resumes the session (with the same settled-gate reconciliation as permissions). - Fail-closed (AD-2/AD-J2-4): unparseable input or no remote answer (daemon stopping) returns 'ask' so Claude Code defers to its local picker; stop() releases pending questions (J1 deadlock guard). - Protocol: add a 'question' transcript entry kind so a question is recorded for backfill (pending vs answered). New pure helpers are one-export modules with unit tests. Daemon integration tests cover the round-trip, fail-closed, and no-deadlock. 83 protocol + 137 daemon tests pass.
Route the Journey-2 question messages the same way as the permission gate, staying payload-blind (correct under E2E ciphertext): - agent.question (daemon -> browser): persist awaiting_input BEFORE the broadcast (alongside agent.permission_request), fire the awaiting-input web push, and add it to the ciphertext cache so a reopening browser replays the pending question instantly. - question.answer (browser -> daemon): flip the row back to running before forwarding it opaquely to the daemon (alongside permission.decision / user.message). Real relay + real Postgres integration tests: awaiting_input on question, running on answer, and cache replay on reopen. Relay suite green (125 pass).
…ey 2, Task 5)
Surface an adopted session's AskUserQuestion and let the operator answer
it remotely (Phase 3, best-effort):
- session reducer: a new 'question' transcript entry + AnswerState
(pending/answering/answered/closed); applyEnvelope('agent.question')
parks at awaiting_input; session.history backfills questions
(answered when answers present, else pending); pendingQuestion() +
markAnswering() (verification-gated like markDeciding); in-flight
answers confirm on the next frame; a still-open question closes when
the session ends (no dead, clickable picker).
- relay-client + store: an answer() path mirroring decide(), sealed
under the session key when E2E.
- QuestionGate.svelte (enterprise-ui): renders each question as a
radio (single) / checkbox (multi) group with an always-available
'Other' free-text field; honest best-effort labeling; pending /
answering / answered / closed states; semantic fieldset/legend +
labels, focus-visible rings, role=alert errors, reduced motion.
Wired into the Transcript next to PermissionGate.
223 web tests pass; svelte-check + eslint clean.
…, Task 6) Final journey task — parametrize the adopted-question deny-feedback path over the answer shapes Claude Code produces (single-select, multi-select, 'Other' free text, multiple questions in one call), each asserting the relayed pick lands in the deny reason. Plus an explicit invariant-#5 test that agent.question reaches the relay as ciphertext, never the cleartext questions. 142 daemon tests pass.
From the 3-agent review (typescript + test + clean-code), all PASSED: - BLOCKING (clean-code): drop the dead `OTHER` constant in QuestionGate.svelte (the 'Other' option uses the otherOn flag). - Important (typescript): stopTurn now releases pendingQuestions for the session (resolve null -> hook returns 'ask'), so interrupt/end no longer deadlocks the hook when an AskUserQuestion is pending (adopted sessions have no AbortController). Regression test added. - Important (typescript): extract stripTelecodeHooks to its own one-export module (strip-telecode-hooks.ts); claude-settings.ts is now the schema + read/write layer. - Important (clean-code): hooks-uninstall.ts JSDoc reformatted multi-line. - Important (test): guard the it.each variant loop (expected non-empty). - Cleanups: drop the unused `questions` field from the pendingQuestions map (YAGNI); relay integration test closes sockets in afterEach (no leak on assertion failure) + parses payloads with the wire schema instead of `as` casts; deny-reason test covers a combined selectedLabels + otherText answer. In-place mutation of the question transcript entry in the question.answer handler is kept for parity with the (single-threaded, documented) permission handler rather than introducing an asymmetric helper. 144 daemon + 223 web + 125 relay + 83 protocol 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 2 — answer adopted-session questions via deny-feedback (Phase 3, Tier 3)
Builds on Journey 1's adopted-sessions plumbing (PR #20). When an externally-started Claude Code session
(plain terminal / IDE sidebar) raises a multiple-choice
AskUserQuestion, telecode now lets you answer itremotely from the dashboard. The
PreToolUsehook intercepts the question, forwards it to the browser, andrelays the human's pick back to the model as deny-feedback — framed as a relayed user answer so the model
adopts it. This is best-effort (AD-4), honestly labelled as such in the UI.
Spike-proven and re-verified end-to-end this pass in a real
claude -psession:AskUserQuestionfired thehook, the injected "chose: Postgres" was adopted, and the model replied "You chose Postgres".
What's in it
agent.question+question.answerwire messages, mirroring the empirically-capturedAskUserQuestiontool_inputshape (noallowsOther— "Other" is always implicitly offered as free text);a
questiontranscript-entry kind for backfill.AskUserQuestionbefore the gate → defensivequestionsFromToolInput(fail-closedaskon unparseable input) → forwardagent.question, block onpendingQuestions, returnpermissionDecision: denycarrying the pick (buildQuestionDenyReason). Fail-closed on no-answer / stop /interrupt / end (never auto-answers).
agent.questionlikeagent.permission_request(awaiting_input+ cache + broadcast) andquestion.answerlikepermission.decision(running+ forward), staying payload-blind (E2E ciphertext).questionkind, 4 answer states, verification-gated), store/clientanswer(), and a newQuestionGate.sveltecard (enterprise-ui: radio/checkbox + always-available "Other" free text; honestbest-effort labeling; pending / answering / answered / closed states).
Tests & gates
prettier · svelte-check (0/0) all green.
variant coverage (single / multi / Other / multi-question), fail-closed paths, no-deadlock guards
(stop + interrupt), and an explicit invariant-Professional README + structured docs (with dedicated E2E & pairing guides) #5
agent.question-is-ciphertext test.Review
3-agent code review (typescript + test + clean-code) — all PASSED. Findings fixed in
c8b6265(notably a realstopTurndeadlock on interrupt/end with a pending question, now fail-closed with a regression test).Notes
"answer on " UI state needs the
Notification(idle) signal and lands in Journey 3 (AD-J2-5).@telecode/clirepublish for adoption to work on the live deployment.