design phase 9 w14.1 claude elicitation - sinrimin/harness-nexus GitHub Wiki

Design โ€” Ask User for claude-code channels: ACP elicitation wiring (Phase 9 W14.1)

Status: IMPLEMENTED 2026-09-17 (rig E2E results in ยง7) Research/ground truth: research-phase-9-w14.1-claude-ground-truth.md Companion fix (merged first): fix/p9-w14.1-claude-todo-env โ€” the claude plan/todo lane revival via CLAUDE_CODE_ENABLE_TODO_TOOLS=1.

1. Problem

claude-code's AskUserQuestion (and MCP-server elicitations mounted under claude) are fully bridged by the ACP wrapper, but the portal never sees them: our daemon's initialize sends clientCapabilities: {}, so the wrapper adds AskUserQuestion to disallowedTools (the model never gets the tool) and would not forward MCP elicitations either. Every other target has NO adapter- side elicitation bridge (research ยง1), so this wave wires the client side once and claude is the only producer โ€” deliberately not per-target gating.

2. Wire (packages/shared realtime.ts)

Two stream events + one request/response pair, mirroring the permission lifecycle exactly:

// stream: daemon โ†’ server โ†’ browser (and into the daemon history ring, so a
// resync re-shows the card; the daemon-side pending map survives resync)
{ kind: 'elicitation_request', requestId, message โ‰ค2048,
  fields: ElicitationField[] โ‰ค16,        // MAY be empty โ€” see ยง3
  toolCallId? }
{ kind: 'elicitation_resolved', requestId,
  outcome: 'accepted' | 'declined' | 'cancelled' | 'timeout' }

// browser โ†’ server โ†’ daemon (verbatim forward both hops):
{ sessionId, requestId, action: 'accept' | 'decline' | 'cancel',
  values?: Record<string, string | number | boolean | string[]> }  // accept only

ElicitationField is OUR bounded rendering hint, extracted daemon-side โ€” never the raw JSON schema on the wire:

{ name โ‰ค128, type: 'text'|'number'|'integer'|'boolean'|'enum'|'multi',
  title? โ‰ค256, description? โ‰ค1024, placeholder? โ‰ค256, required?,
  options?: { value โ‰ค1024, label? โ‰ค256, description? โ‰ค1024 }[] โ‰ค32 }
  • accept โ†’ daemon answers the adapter elicitation/create JSON-RPC request with {action:'accept', content: values ?? {}} (values keyed by field name, verbatim โ€” the wrapper folds them into the tool input).
  • decline โ†’ {action:'decline'}; cancel โ†’ {action:'cancel'}.

3. Daemon (packages/cli/src/daemon/)

  • agent-connection.ts: initialize now advertises clientCapabilities: { elicitation: { form: {} } } (globally โ€” other adapters ignore it). Incoming elicitation/create requests (top-level method, NOT the session/request envelope) dispatch to a new setElicitationHandler; respondElicitation(jsonrpcId, response) answers. Unhandled agent-side requests are still dropped as before.
  • chat.ts: DaemonSession.elicitations map (requestId โ†’ {jsonrpcId, timer}), mirroring permissions. The handler mints a UUID requestId, arms a 75s backstop timer (cancel + elicitation_resolved timeout โ€” the server watchdog is the primary), and emits elicitation_request with takeElicitationView(params):
    • message clamped to 2048 (missing โ†’ '').
    • toolCallId passed through when a string (โ‰ค256).
    • requestedSchema.properties (โ‰ค16 entries) reduced per property: oneOf/enum consts โ†’ enum options (string entries, or objects' const + title/description); type:'array' + items consts โ†’ multi; type:'number'|'integer'|'boolean'|'string' โ†’ the matching kind (options win over type for strings); required[] membership โ†’ required:true; title/description/placeholder clamped.
    • Structurally unusable properties (nested object, no type + no options) are DROPPED. An EMPTY field list is valid and surfaces as a message-only card (decline/cancel available, no accept) โ€” the user still sees that the agent asked, and the agent still gets an answer.
  • Teardown (closeInternal) clears elicitation timers like permission's.
  • DAEMON_VERSION โ†’ 0.21.0-p9w14.1.

4. Server (packages/server/src/realtime/chat.ts + plugins/realtime.ts)

  • onStream arms an elicitation watchdog per elicitation_request โ€” same policy and same timeout value as permissions (CHAT_PERMISSION_TIMEOUT_MS): timeout โ‡’ chat:elicitation.respond {action:'cancel'} to the daemon + elicitation_resolved timeout to the channel. One config knob for both is deliberate (both are "the agent waits on a human" latencies).
  • onElicitationRespond(ownerId, sessionId, requestId, action, values): owner gate โ†’ clear watchdog โ†’ forward verbatim to the daemon โ†’ emit elicitation_resolved (accepted/declined/cancelled). Unknown ids โ†’ ELICITATION_NOT_FOUND (404-style, mirroring permission's 404-ism).
  • /app registers chat:elicitation.respond (validated by the shared request schema, ack {accepted:true} / {error:code}).
  • Session teardown clears elicitationTimers.

5. Web (apps/web)

  • realtime.ts hand-mirrors the new event/field types.
  • fold.ts: elicitations: ElicitationCardState[] slice ({requestId, message, fields, toolCallId?, settled}), arms mirror the permission pair (request upserts by requestId, resolved sets settled).
  • components/chat/elicitation-cards.tsx renders unsettled cards inline next to PermissionCards (same warn-tone card chrome โ€” a pending question is a decision). Per field: enum โ†’ radio rows (label + description), multi โ†’ checkbox rows, boolean โ†’ checkbox, number/integer โ†’ number input, text โ†’ text input (autoComplete="off", spellCheck={false} โ€” protocol-ish inputs). Answer submits when every required field has a value; Decline/Cancel always available. Settled cards vanish (the answer lands in the following assistant message โ€” no settled-remnant line, unlike permissions whose choice has no other visible trace).
  • Strings in i18n/strings/chat.ts (en/zh).

6. Fixture + tests

  • fixtures/acp-agent.mjs: prompt containing ask-user sends a real elicitation/create request (enum + text + boolean + integer fields, question_0 required), waits for the response, and echoes elicitation answered: <json> as the turn's message โ€” exercising the full request/response round trip.
  • shared: schema accept/reject cases (field types, value union, caps).
  • cli: takeElicitationView unit cases (oneOf consts, string enum, arrayโ†’ multi, required, clamps, drops) + fixture round-trip integration (event โ†’ chat:elicitation.respond accept โ†’ echoed answer).
  • server: relay + respond routing + watchdog timeout (mirrors the permission tests).

7. Rig results (2026-09-17, daemon 0.21.0-p9w14.1)

Browser E2E on a fresh claude-code channel (/root, gateway model deepseek-v4-flash, wrapper 0.78.0 โ†’ SDK-bundled CLI 2.1.270):

  • Ask User โœ“ โ€” "Use the AskUserQuestion tool to ask me: which color do you prefer, red or blue?" โ†’ the question card rendered inline (running tool row + Red/Blue radios with descriptions + the Other custom text field + ๅ›ž็ญ”/ๆ‹’็ป/ๅ–ๆถˆ). Selecting Red + ๅ›ž็ญ” settled the card (it VANISHES per ยง5) and the turn continued with the model acknowledging "You prefer red. ๐Ÿ‘" (turn tail 41.4s ยท ctx 22k/200k) โ€” the values round-tripped verbatim into the tool input.
  • Plan lane โœ“ (companion env fix, same deploy) โ€” "Use TaskCreate to create two todos alpha/beta, mark alpha completed" โ†’ the model replied DONE and the TodoPanel lit up above the composer: ๅทฒๅฎŒๆˆ 1 ยท ๅพ…ๅŠž 1 (1/2), expanded list showing alpha + beta. The W14 claude column is live.
  • The MCP-elicitation path was not exercised (no MCP server mounted under the chat adapter on the rig); codex / opencode / dsh unchanged โ€” no elicitation producers (research ยง1).
  • Screenshot: /tmp/w14.1-rig-e2e.png (ephemeral).

Out of scope

  • url-mode elicitation is NOT advertised (elicitation.url absent): the wrapper would then send elicitation/create {mode:'url'} + a session/complete_elicitation notification โ€” an OAuth-jump class of UI we do not want in the portal. If ever needed, advertise + render a link card.
  • codex / opencode / dsh ask-user: blocked adapter-side (research ยง1) โ€” revisit when codex-acp implements EventMsg::RequestUserInput or opencode adds a bridge.
  • Numeric minimum/maximum, string format/pattern hints: fields render as plain inputs; validation stays adapter-side (it owns the schema).
  • Nested-object properties: dropped by the reducer (ยง3) โ€” the message-only card is the honest fallback.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ