@agentdeck/bridge 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/ask-gate.d.ts +113 -0
  2. package/dist/ask-gate.d.ts.map +1 -0
  3. package/dist/ask-gate.js +110 -0
  4. package/dist/ask-gate.js.map +1 -0
  5. package/dist/auth.d.ts +8 -0
  6. package/dist/auth.d.ts.map +1 -1
  7. package/dist/auth.js +15 -0
  8. package/dist/auth.js.map +1 -1
  9. package/dist/awaiting-overlay.d.ts +54 -6
  10. package/dist/awaiting-overlay.d.ts.map +1 -1
  11. package/dist/awaiting-overlay.js +109 -26
  12. package/dist/awaiting-overlay.js.map +1 -1
  13. package/dist/broadcast.d.ts +1 -1
  14. package/dist/broadcast.d.ts.map +1 -1
  15. package/dist/broadcast.js +10 -7
  16. package/dist/broadcast.js.map +1 -1
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +74 -4
  19. package/dist/cli.js.map +1 -1
  20. package/dist/daemon-server.d.ts.map +1 -1
  21. package/dist/daemon-server.js +239 -20
  22. package/dist/daemon-server.js.map +1 -1
  23. package/dist/daemon.js +1 -1
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/http-auth-gate.d.ts +40 -0
  26. package/dist/http-auth-gate.d.ts.map +1 -0
  27. package/dist/http-auth-gate.js +46 -0
  28. package/dist/http-auth-gate.js.map +1 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +15 -9
  32. package/dist/index.js.map +1 -1
  33. package/dist/mdns.d.ts +7 -1
  34. package/dist/mdns.d.ts.map +1 -1
  35. package/dist/mdns.js +7 -3
  36. package/dist/mdns.js.map +1 -1
  37. package/dist/modules/broadcast-module.d.ts.map +1 -1
  38. package/dist/modules/broadcast-module.js +3 -1
  39. package/dist/modules/broadcast-module.js.map +1 -1
  40. package/dist/modules/mdns-module.d.ts.map +1 -1
  41. package/dist/modules/mdns-module.js +3 -1
  42. package/dist/modules/mdns-module.js.map +1 -1
  43. package/dist/observed-inject.d.ts +21 -5
  44. package/dist/observed-inject.d.ts.map +1 -1
  45. package/dist/observed-inject.js +85 -22
  46. package/dist/observed-inject.js.map +1 -1
  47. package/dist/observed-steering.d.ts +38 -0
  48. package/dist/observed-steering.d.ts.map +1 -1
  49. package/dist/observed-steering.js +53 -0
  50. package/dist/observed-steering.js.map +1 -1
  51. package/dist/passive-observer.d.ts +9 -0
  52. package/dist/passive-observer.d.ts.map +1 -1
  53. package/dist/passive-observer.js +90 -5
  54. package/dist/passive-observer.js.map +1 -1
  55. package/dist/permission-resolver.d.ts +11 -0
  56. package/dist/permission-resolver.d.ts.map +1 -1
  57. package/dist/permission-resolver.js +23 -3
  58. package/dist/permission-resolver.js.map +1 -1
  59. package/dist/session-registry.d.ts +23 -2
  60. package/dist/session-registry.d.ts.map +1 -1
  61. package/dist/session-registry.js +56 -5
  62. package/dist/session-registry.js.map +1 -1
  63. package/package.json +3 -3
@@ -25,9 +25,10 @@ import { SessionFocusRelay } from './session-focus-relay.js';
25
25
  import { SubagentTimelineTracker } from './subagent-timeline.js';
26
26
  import { getRemoteSession, getRemoteSender, listRemoteEnriched, } from './remote-sessions.js';
27
27
  import { createPushChannelHandler } from './session-push-channel.js';
28
- import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
29
- import { registerPending, resolvePending, sweepStalePending, drainAllPending, isPendingRequest } from './permission-resolver.js';
30
- import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
28
+ import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, advanceAskUserQuestionOverlay, getAskUserQuestionAnswers, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
29
+ import { askEchoMatches, askGateDecision, askPressVerdict } from './ask-gate.js';
30
+ import { registerPending, resolvePending, resolvePendingWithReason, sweepStalePending, drainAllPending, isPendingRequest, } from './permission-resolver.js';
31
+ import { shouldHoldPreToolUse, beginAskGate, gateReleased, buildGateQuestion, buildAskAnswerReason, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
31
32
  import { notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
32
33
  import { resolveSessionIdPrefix } from './session-id-resolve.js';
33
34
  import { injectObservedSelection, injectObservedText } from './observed-inject.js';
@@ -46,6 +47,15 @@ const OBSERVED_APPROVAL_ENABLED = process.env.AGENTDECK_OBSERVED_APPROVAL !== '0
46
47
  * call to Claude's own permission flow. Must stay well under the hook curl's
47
48
  * --max-time 60 so the release reaches Claude before curl gives up. */
48
49
  const OBSERVED_APPROVAL_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGENTDECK_APPROVAL_HOLD_MS) || 25_000));
50
+ /** AskUserQuestion ask-gate (PreToolUse hold so a device can answer the
51
+ * question). Only ever engaged when this daemon has no way to type the answer
52
+ * into the session's terminal, so it costs the terminal user nothing whenever
53
+ * injection is available. Kill switch for field issues. */
54
+ const ASK_GATE_ENABLED = process.env.AGENTDECK_ASK_GATE !== '0';
55
+ /** Hold budget for an ask-gate. Longer than the permission gate because the
56
+ * user may answer several question groups in one call, and still under the
57
+ * hook curl's --max-time 60 so the release reaches Claude. */
58
+ const ASK_GATE_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGENTDECK_ASK_GATE_HOLD_MS) || 45_000));
49
59
  /** Inactivity after which an unclosed APME run is treated as abandoned and
50
60
  * finalized. Deliberately generous: an orphan stays reapable forever, so
51
61
  * closing it late costs nothing, while reaping a live run corrupts the unit
@@ -55,9 +65,10 @@ const OBSERVED_APPROVAL_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(proces
55
65
  const APME_ABANDONED_RUN_STALE_SEC = Math.max(600, Number(process.env.AGENTDECK_APME_ABANDON_SEC) || 7200);
56
66
  import { VoiceManager } from './voice.js';
57
67
  import { VoiceAssistantManager } from './voice-assistant.js';
58
- import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
68
+ import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
59
69
  import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
60
70
  import { isLocalConnection, validateToken } from './auth.js';
71
+ import { buildPublicHealth, gateHttpRequest, isAuthorizedHttpRequest } from './http-auth-gate.js';
61
72
  import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
62
73
  import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
63
74
  import { handlePixooWake } from './pixoo/pixoo-client.js';
@@ -893,6 +904,10 @@ export async function startDaemon(opts) {
893
904
  log(`[agentdeck] Swift daemon detected on port ${probePort}. Requesting shutdown to take over...`);
894
905
  await requestDaemonShutdown(probePort);
895
906
  await waitForDaemonExit(probePort);
907
+ // …and until the socket is actually released. NWListener.cancel()
908
+ // returns before the port is free, so "stopped answering" alone would
909
+ // let the bind below land on a fallback port.
910
+ await waitForPortBindable(probePort);
896
911
  removeDaemonInfo();
897
912
  }
898
913
  else {
@@ -937,6 +952,7 @@ export async function startDaemon(opts) {
937
952
  log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
938
953
  await requestDaemonShutdown(requestedPort);
939
954
  await waitForDaemonExit(requestedPort);
955
+ await waitForPortBindable(requestedPort);
940
956
  }
941
957
  else {
942
958
  // Daemon alive but not in our registry — race condition or stale state
@@ -989,6 +1005,33 @@ export async function startDaemon(opts) {
989
1005
  // Codex sessions known only from `codex_*` hooks — the backstop for when the
990
1006
  // process scan can't see one (lsof timeout, no rollout held open).
991
1007
  const hookCodexSessions = new HookCodexSessions();
1008
+ // Declared before the HTTP server: the PreToolUse route reads it to decide
1009
+ // whether this daemon can type into a session's terminal, and hooks start
1010
+ // arriving the moment the port binds — several hundred milliseconds before
1011
+ // the rest of startup finishes. A later `const` would leave that window
1012
+ // throwing on the temporal dead zone.
1013
+ const passiveSessionObserver = new PassiveSessionObserver();
1014
+ /** Observed Claude sessions whose AskUserQuestion PreToolUse is held open for
1015
+ * a device answer, keyed by the bare Claude session UUID. Its presence is
1016
+ * what makes the session's options pressable (`liveAnswerable`) and what
1017
+ * routes a `select_option` press to the gate instead of terminal injection. */
1018
+ const pendingAskGateBySession = new Map();
1019
+ /** Per session UUID: the timestamp until which `select_option` presses are
1020
+ * swallowed because a terminal injection is typing (or has just finished).
1021
+ * A second press landing mid-injection would aim its arrow keys at a picker
1022
+ * that is about to advance to the next question, selecting the wrong option
1023
+ * there — so the guard spans the keystrokes plus a short settle window. */
1024
+ const injectionGuardUntil = new Map();
1025
+ /** Per session UUID: when the next ask-gate press will be accepted. An
1026
+ * answer is irreversible (it resolves the agent's hook), and a grouped
1027
+ * prompt advances on each one, so two frames from a bouncing key would
1028
+ * answer two different questions with the same index. */
1029
+ const askPressGuardUntil = new Map();
1030
+ const ASK_PRESS_DEBOUNCE_MS = 400;
1031
+ /** Upper bound for one injection attempt (host ladder + paced keystrokes). */
1032
+ const INJECTION_GUARD_MAX_MS = 60_000;
1033
+ /** Settle window after the keys land, covering the TUI's own redraw. */
1034
+ const INJECTION_SETTLE_MS = 1_200;
992
1035
  // Declare early — HTTP /health handler references this in its closure.
993
1036
  // Must be declared before the HTTP server so it's initialized (not in TDZ)
994
1037
  // when the first /health request arrives.
@@ -1089,6 +1132,32 @@ export async function startDaemon(opts) {
1089
1132
  let subagentTimeline = null;
1090
1133
  // ===== HTTP server =====
1091
1134
  const httpServer = createServer((req, res) => {
1135
+ // LAN default-deny (issue #145): a request that is neither same-machine
1136
+ // nor token-bearing reaches exactly one route — a minimal GET /health
1137
+ // with no pairingToken, no module/device inventory, no session state.
1138
+ // Everything else 401s here, before any route dispatch. The per-route
1139
+ // token checks further down remain as defense in depth.
1140
+ {
1141
+ const gatePathname = (() => {
1142
+ try {
1143
+ return new URL(req.url ?? '/', 'http://localhost').pathname;
1144
+ }
1145
+ catch {
1146
+ return '/';
1147
+ }
1148
+ })();
1149
+ const decision = gateHttpRequest(req.method ?? 'GET', gatePathname, isAuthorizedHttpRequest(req));
1150
+ if (decision === 'public-health') {
1151
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1152
+ res.end(JSON.stringify(buildPublicHealth(port)));
1153
+ return;
1154
+ }
1155
+ if (decision === 'deny') {
1156
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1157
+ res.end(JSON.stringify({ error: 'Unauthorized — pairing token required for LAN access' }));
1158
+ return;
1159
+ }
1160
+ }
1092
1161
  // APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
1093
1162
  if ((req.url ?? '').startsWith('/apme')) {
1094
1163
  const ip = req.socket.remoteAddress ?? '';
@@ -1112,7 +1181,10 @@ export async function startDaemon(opts) {
1112
1181
  }
1113
1182
  const parsedUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
1114
1183
  const pathname = parsedUrl.pathname;
1115
- // Health check is public (no auth) used by iOS/Android for pairing token discovery
1184
+ // Full health only reachable same-machine or with a valid pairing token
1185
+ // (the LAN gate above serves unauthenticated peers buildPublicHealth
1186
+ // instead). pairingToken stays here for local consumers: sibling bridges,
1187
+ // the TUI, and the macOS companion's file-less fallback.
1116
1188
  if (req.method === 'GET' && pathname === '/health') {
1117
1189
  const snap = core.stateMachine.getSnapshot();
1118
1190
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1415,8 +1487,8 @@ export async function startDaemon(opts) {
1415
1487
  }
1416
1488
  // ===== Card Feed pull sync (M6) — wake-sync-sleep battery clients =====
1417
1489
  // Auth mirrors /apme: local connections are free; anything else needs the
1418
- // pairing token (?token=). Devices hold it from provisioning; /health
1419
- // exposes it for LAN pairing.
1490
+ // pairing token (?token=). Devices hold it from provisioning — since
1491
+ // issue #145 the daemon no longer hands it to unauthenticated LAN peers.
1420
1492
  // ===== Glance Frame (M8) — daemon-rendered pixels of the glance =====
1421
1493
  // The device-side glance renderer is the offline fallback; this is the
1422
1494
  // rich face. `?format=png` returns the exact dithered panel pixels, so a
@@ -1789,6 +1861,9 @@ export async function startDaemon(opts) {
1789
1861
  }
1790
1862
  else if (mapped === 'session_end') {
1791
1863
  clearSteeringSession(claudeSid);
1864
+ pendingAskGateBySession.delete(claudeSid);
1865
+ injectionGuardUntil.delete(claudeSid);
1866
+ askPressGuardUntil.delete(claudeSid);
1792
1867
  }
1793
1868
  const currentOverlay = getAwaitingOverlay(claudeSid);
1794
1869
  let cleared = false;
@@ -2118,7 +2193,66 @@ export async function startDaemon(opts) {
2118
2193
  core.broadcastSessionsList().catch(() => { });
2119
2194
  return;
2120
2195
  }
2121
- // 2. Device-approval gate: hold the response for a device decision,
2196
+ // 2. AskUserQuestion ask-gate: the structured overlay was set from
2197
+ // this same hook above, so devices are already showing the real
2198
+ // choices. Hold the call so a device press can answer it — the
2199
+ // only delivery channel a sandboxed daemon has, since it cannot
2200
+ // type into the user's terminal. Skipped whenever injection IS
2201
+ // available: answering the live picker costs the person sitting at
2202
+ // that terminal nothing, whereas a hold makes them wait for it to
2203
+ // appear. Timeout releases with an empty body, so an unanswered
2204
+ // question just shows up in the terminal as usual.
2205
+ if (claudeSid && toolName === 'AskUserQuestion') {
2206
+ const askOverlay = getAwaitingOverlay(claudeSid);
2207
+ if (askOverlay?.kind === 'option') {
2208
+ const obs = passiveSessionObserver.collect([])
2209
+ .find((s) => s.id === `observed:claude:${claudeSid}`);
2210
+ const worthHolding = askGateDecision({
2211
+ enabled: ASK_GATE_ENABLED,
2212
+ clientCount: core.wsServer.getClientCount(),
2213
+ observed: obs,
2214
+ });
2215
+ const gate = worthHolding.hold
2216
+ ? beginAskGate({
2217
+ sessionId: claudeSid,
2218
+ clientCount: core.wsServer.getClientCount(),
2219
+ enabled: ASK_GATE_ENABLED,
2220
+ })
2221
+ : { hold: false, requestId: undefined, reason: worthHolding.reason };
2222
+ if (gate.hold && gate.requestId) {
2223
+ const requestId = gate.requestId;
2224
+ const askToolUseId = typeof json.tool_use_id === 'string' ? json.tool_use_id : undefined;
2225
+ debug('daemon', `AskUserQuestion gate held: req=${requestId} sid=${claudeSid.slice(0, 8)}`);
2226
+ pendingAskGateBySession.set(claudeSid, { requestId, toolUseId: askToolUseId });
2227
+ core.broadcastSessionsList().catch(() => { }); // liveAnswerable → true
2228
+ registerPending(requestId, res, {
2229
+ sessionId: claudeSid,
2230
+ tool: toolName,
2231
+ timeoutMs: ASK_GATE_HOLD_MS,
2232
+ onResolved: (decision) => {
2233
+ pendingAskGateBySession.delete(claudeSid);
2234
+ // `undecided: false` always: an unanswered question says
2235
+ // nothing about what Claude auto-approves, so it must not
2236
+ // feed the auto-approval learner.
2237
+ gateReleased(claudeSid, requestId, {
2238
+ undecided: false, tool: toolName, toolInput,
2239
+ });
2240
+ // Answered → the tool call is denied and never runs, so no
2241
+ // PostToolUse will ever arrive to clear the overlay; clear
2242
+ // it here. Timed out → the picker is now on the user's own
2243
+ // screen, so the overlay stays as a display-only prompt and
2244
+ // liveAnswerable falls back to false.
2245
+ if (decision === 'deny')
2246
+ clearAskUserQuestionOverlay(claudeSid, askToolUseId);
2247
+ core.broadcastSessionsList().catch(() => { });
2248
+ },
2249
+ });
2250
+ return; // response held open — resolved by device / timeout
2251
+ }
2252
+ debug('daemon', `AskUserQuestion not held: ${gate.reason} (${claudeSid.slice(0, 8)})`);
2253
+ }
2254
+ }
2255
+ // 3. Device-approval gate: hold the response for a device decision,
2122
2256
  // but ONLY for calls the precision guards say Claude would
2123
2257
  // genuinely prompt for (see shouldHoldPreToolUse — mode gate,
2124
2258
  // never-prompt/prompt-prone sets, allowlist prediction, learned
@@ -2401,6 +2535,12 @@ export async function startDaemon(opts) {
2401
2535
  if (!socket.destroyed)
2402
2536
  socket.destroy();
2403
2537
  });
2538
+ // All-interfaces bind is deliberate — companion apps, ESP32 boards, and
2539
+ // pull-sync e-ink clients live on the LAN — and the security boundary is the
2540
+ // pairing token (http-auth-gate.ts default-denies unauthenticated LAN
2541
+ // requests; the WS server has always token-gated non-local peers). Users
2542
+ // with no LAN devices can opt into a loopback-only bind (issue #145).
2543
+ const bindHost = process.env.AGENTDECK_LOOPBACK_ONLY === '1' ? '127.0.0.1' : '0.0.0.0';
2404
2544
  await new Promise((resolve, reject) => {
2405
2545
  httpServer.on('error', (err) => {
2406
2546
  if (err.code === 'EADDRINUSE') {
@@ -2411,7 +2551,7 @@ export async function startDaemon(opts) {
2411
2551
  reject(err);
2412
2552
  }
2413
2553
  });
2414
- httpServer.listen(port, '0.0.0.0', () => resolve());
2554
+ httpServer.listen(port, bindHost, () => resolve());
2415
2555
  }).catch(async (err) => {
2416
2556
  // Handle race condition: port became unavailable after our pre-bind probe.
2417
2557
  // This is the concurrent-start case (e.g. two logon-trigger fires landing
@@ -2439,13 +2579,19 @@ export async function startDaemon(opts) {
2439
2579
  log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
2440
2580
  await new Promise((resolve, reject) => {
2441
2581
  httpServer.on('error', (e) => reject(e));
2442
- httpServer.listen(port, '0.0.0.0', () => resolve());
2582
+ httpServer.listen(port, bindHost, () => resolve());
2443
2583
  });
2444
2584
  }
2445
2585
  else {
2446
2586
  throw err;
2447
2587
  }
2448
2588
  });
2589
+ if (bindHost === '127.0.0.1') {
2590
+ log(`[agentdeck] AGENTDECK_LOOPBACK_ONLY=1 — daemon bound to 127.0.0.1:${port}; LAN devices (companion apps, ESP32/WiFi boards) cannot connect.`);
2591
+ }
2592
+ else {
2593
+ log(`[agentdeck] Daemon listening on all interfaces (:${port}) — LAN requests require the pairing token (~/.agentdeck/auth-token). Set AGENTDECK_LOOPBACK_ONLY=1 for a loopback-only bind.`);
2594
+ }
2449
2595
  // Write daemon.json for client discovery (must be after successful bind).
2450
2596
  // Keep the original startedAt stable when the self-heal timer rewrites it.
2451
2597
  const daemonInfo = { port, pid: process.pid, startedAt: new Date().toISOString() };
@@ -2863,7 +3009,7 @@ export async function startDaemon(opts) {
2863
3009
  }
2864
3010
  }
2865
3011
  log(`[agentdeck] WebSocket server ready on port ${port}`);
2866
- log(`[agentdeck] Pairing URL: ${core.wsUrl}`);
3012
+ log('[agentdeck] Pairing credential ready; run "agentdeck qr" to pair a device.');
2867
3013
  // Initialize APME store + collector so the daemon can serve /apme/* HTTP
2868
3014
  // routes. `setApme` on core is gated against the `daemon` meta-session so
2869
3015
  // register/deregister won't open a bogus run. Session bridges opening their
@@ -2908,7 +3054,6 @@ export async function startDaemon(opts) {
2908
3054
  }
2909
3055
  // Register session
2910
3056
  core.registerSession('daemon');
2911
- const passiveSessionObserver = new PassiveSessionObserver();
2912
3057
  // The observer scans in the background now (collect() returns the cache
2913
3058
  // immediately). When a scan lands fresh observations, push them out via
2914
3059
  // the debounced broadcast so clients don't wait for the next 10 s poll.
@@ -2939,12 +3084,15 @@ export async function startDaemon(opts) {
2939
3084
  // "stopping at next tool" / queued-directive badges from these.
2940
3085
  if (!s.id.startsWith('observed:claude:'))
2941
3086
  return { ...s, liveAnswerable: false };
2942
- // Can this daemon type into the session's live prompt? Only observed
2943
- // Claude routes select_option to the injector, and only with a host to
2944
- // aim at. Sent in both polarities — decks read it to decide whether an
2945
- // AskUserQuestion option is a button or an inert mirror of the terminal.
2946
- const liveAnswerable = Boolean(s.tty || s.appName);
2947
- const snap = steeringSnapshot(s.id.slice('observed:claude:'.length));
3087
+ // Will a device press on this session's options reach the agent? Two
3088
+ // rungs qualify: this daemon can type into the session's live prompt
3089
+ // (a reachable terminal/app host), or it is holding the session's
3090
+ // AskUserQuestion open and will answer it with the device's choice.
3091
+ // Sent in both polarities — decks read it to decide whether an option
3092
+ // is a button or an inert mirror of the terminal.
3093
+ const uuid = s.id.slice('observed:claude:'.length);
3094
+ const liveAnswerable = Boolean(s.tty || s.appName) || pendingAskGateBySession.has(uuid);
3095
+ const snap = steeringSnapshot(uuid);
2948
3096
  return {
2949
3097
  ...s,
2950
3098
  liveAnswerable,
@@ -3468,6 +3616,51 @@ export async function startDaemon(opts) {
3468
3616
  }
3469
3617
  if (type === 'respond' || type === 'select_option') {
3470
3618
  const ov = getAwaitingOverlay(uuid);
3619
+ // Stale-press guard. A multi-group AskUserQuestion moves to the next
3620
+ // question the instant one is answered, so an index pressed against the
3621
+ // question the device was still displaying would select the wrong option
3622
+ // in the new list. Devices that echo what they showed get validated;
3623
+ // re-broadcasting re-syncs them to the live question.
3624
+ const echo = typeof command.question === 'string' ? command.question : undefined;
3625
+ if (ov?.kind === 'option' && echo && ov.question && !askEchoMatches(echo, ov.question)) {
3626
+ debug('daemon', `observed ${type} dropped: answers a stale question (${uuid.slice(0, 8)})`);
3627
+ core.broadcastSessionsList().catch(() => { });
3628
+ return;
3629
+ }
3630
+ // Held AskUserQuestion gate — this daemon owns the prompt (the picker
3631
+ // never reached the user's terminal). Record the answer, show the next
3632
+ // group, and resolve the hook once every group has one. Every rejection
3633
+ // below is a press that would otherwise commit an answer the user did
3634
+ // not give, so `askPressVerdict` is deliberately strict.
3635
+ const askGate = pendingAskGateBySession.get(uuid);
3636
+ if (askGate) {
3637
+ const now = Date.now();
3638
+ if (now < (askPressGuardUntil.get(uuid) ?? 0)) {
3639
+ debug('daemon', `ask-gate press dropped: too soon after the last one (${uuid.slice(0, 8)})`);
3640
+ return;
3641
+ }
3642
+ const verdict = askPressVerdict({
3643
+ overlay: ov, gateToolUseId: askGate.toolUseId, command,
3644
+ });
3645
+ if (!verdict.ok) {
3646
+ debug('daemon', `ask-gate press dropped: ${verdict.reason} (${uuid.slice(0, 8)})`);
3647
+ if (verdict.resync)
3648
+ core.broadcastSessionsList().catch(() => { });
3649
+ return;
3650
+ }
3651
+ askPressGuardUntil.set(uuid, now + ASK_PRESS_DEBOUNCE_MS);
3652
+ const { label } = verdict;
3653
+ const step = advanceAskUserQuestionOverlay(uuid, label);
3654
+ if (step === 'complete') {
3655
+ resolvePendingWithReason(askGate.requestId, 'deny', buildAskAnswerReason(getAskUserQuestionAnswers(uuid)));
3656
+ debug('daemon', `ask-gate answered (${uuid.slice(0, 8)}): "${label.slice(0, 40)}"`);
3657
+ }
3658
+ else {
3659
+ // Next question group — devices swap to it on this broadcast.
3660
+ core.broadcastSessionsList().catch(() => { });
3661
+ }
3662
+ return;
3663
+ }
3471
3664
  if (ov?.requestId) {
3472
3665
  const allow = type === 'select_option'
3473
3666
  ? command.index === 0
@@ -3481,16 +3674,42 @@ export async function startDaemon(opts) {
3481
3674
  // terminal isn't reachable the prompt simply stays for the user.
3482
3675
  if (type === 'select_option' && typeof command.index === 'number') {
3483
3676
  const idx = command.index;
3677
+ const now = Date.now();
3678
+ if (now < (injectionGuardUntil.get(uuid) ?? 0)) {
3679
+ debug('daemon', `observed select_option dropped: injection in flight (${uuid.slice(0, 8)})`);
3680
+ return;
3681
+ }
3682
+ injectionGuardUntil.set(uuid, now + INJECTION_GUARD_MAX_MS);
3484
3683
  const obs = passiveSessionObserver.collect([])
3485
3684
  .find((s) => s.id === `observed:claude:${uuid}`);
3486
3685
  // The option's visible label lets app-hosted prompts press the matching
3487
3686
  // native button instead of guessing cursor movement.
3488
3687
  const label = ov?.options?.[idx]?.label;
3489
- injectObservedSelection({ tty: obs?.tty, appName: obs?.appName, label }, idx).then((r) => {
3688
+ // A grouped AskUserQuestion does not close on its last answer — it
3689
+ // shows a "Review your answers → Submit answers" confirmation. Nobody
3690
+ // is at that screen to press it, so the last answer carries the extra
3691
+ // Enter. Single-question prompts have no such step.
3692
+ //
3693
+ // The count that decides this is what CLAUDE rendered, not what we
3694
+ // parsed: a group we dropped as malformed is still a tab in the user's
3695
+ // form, so trusting `groups.length` would skip the confirmation on a
3696
+ // form that has one — and the agent would wait on it forever.
3697
+ const groupCount = ov?.rawGroupCount ?? ov?.groups?.length ?? 1;
3698
+ const parsedCount = ov?.groups?.length ?? 1;
3699
+ const confirmSubmit = groupCount > 1 && (ov?.activeGroup ?? 0) === parsedCount - 1;
3700
+ injectObservedSelection({ tty: obs?.tty, appName: obs?.appName, label }, idx, { confirmSubmit }).then((r) => {
3701
+ injectionGuardUntil.set(uuid, Date.now() + INJECTION_SETTLE_MS);
3490
3702
  debug('daemon', r.ok
3491
3703
  ? `observed selection injected via ${r.via} (idx ${idx}, ${uuid.slice(0, 8)})`
3492
3704
  : `observed selection NOT injected: ${r.reason} (${uuid.slice(0, 8)})`);
3493
- }).catch(() => { });
3705
+ // The keys landed, so the TUI has moved to the next question group —
3706
+ // move the overlay with it or devices keep offering the answered one.
3707
+ if (r.ok && advanceAskUserQuestionOverlay(uuid, label) !== 'ignored') {
3708
+ core.broadcastSessionsList().catch(() => { });
3709
+ }
3710
+ }).catch(() => {
3711
+ injectionGuardUntil.set(uuid, Date.now() + INJECTION_SETTLE_MS);
3712
+ });
3494
3713
  }
3495
3714
  return;
3496
3715
  }