@agentdeck/bridge 0.2.3 → 1.0.0

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 (185) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/dist/apme/collector.d.ts +4 -0
  3. package/dist/apme/collector.d.ts.map +1 -1
  4. package/dist/apme/collector.js +1 -0
  5. package/dist/apme/collector.js.map +1 -1
  6. package/dist/apme/dashboard-html.d.ts.map +1 -1
  7. package/dist/apme/dashboard-html.js +30 -0
  8. package/dist/apme/dashboard-html.js.map +1 -1
  9. package/dist/apme/http.d.ts.map +1 -1
  10. package/dist/apme/http.js +9 -0
  11. package/dist/apme/http.js.map +1 -1
  12. package/dist/apme/index.d.ts.map +1 -1
  13. package/dist/apme/index.js +14 -3
  14. package/dist/apme/index.js.map +1 -1
  15. package/dist/apme/judge-detect.d.ts +26 -0
  16. package/dist/apme/judge-detect.d.ts.map +1 -0
  17. package/dist/apme/judge-detect.js +69 -0
  18. package/dist/apme/judge-detect.js.map +1 -0
  19. package/dist/apme/runner.d.ts +7 -0
  20. package/dist/apme/runner.d.ts.map +1 -1
  21. package/dist/apme/runner.js +188 -33
  22. package/dist/apme/runner.js.map +1 -1
  23. package/dist/apme/settings.d.ts +10 -8
  24. package/dist/apme/settings.d.ts.map +1 -1
  25. package/dist/apme/settings.js +11 -16
  26. package/dist/apme/settings.js.map +1 -1
  27. package/dist/awaiting-overlay.d.ts +17 -4
  28. package/dist/awaiting-overlay.d.ts.map +1 -1
  29. package/dist/awaiting-overlay.js +39 -8
  30. package/dist/awaiting-overlay.js.map +1 -1
  31. package/dist/bridge-core.d.ts +10 -0
  32. package/dist/bridge-core.d.ts.map +1 -1
  33. package/dist/bridge-core.js +50 -0
  34. package/dist/bridge-core.js.map +1 -1
  35. package/dist/claude-permission-rules.d.ts +55 -0
  36. package/dist/claude-permission-rules.d.ts.map +1 -0
  37. package/dist/claude-permission-rules.js +197 -0
  38. package/dist/claude-permission-rules.js.map +1 -0
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +10 -1
  41. package/dist/cli.js.map +1 -1
  42. package/dist/d200h/hid-protocol.d.ts +62 -0
  43. package/dist/d200h/hid-protocol.d.ts.map +1 -0
  44. package/dist/d200h/hid-protocol.js +124 -0
  45. package/dist/d200h/hid-protocol.js.map +1 -0
  46. package/dist/d200h/image-renderer.d.ts +24 -0
  47. package/dist/d200h/image-renderer.d.ts.map +1 -0
  48. package/dist/d200h/image-renderer.js +321 -0
  49. package/dist/d200h/image-renderer.js.map +1 -0
  50. package/dist/daemon-server.d.ts.map +1 -1
  51. package/dist/daemon-server.js +374 -26
  52. package/dist/daemon-server.js.map +1 -1
  53. package/dist/daemon.js +1 -1
  54. package/dist/esp32-serial.d.ts +30 -0
  55. package/dist/esp32-serial.d.ts.map +1 -1
  56. package/dist/esp32-serial.js +148 -11
  57. package/dist/esp32-serial.js.map +1 -1
  58. package/dist/fallback-task-timeline.d.ts.map +1 -1
  59. package/dist/fallback-task-timeline.js +9 -2
  60. package/dist/fallback-task-timeline.js.map +1 -1
  61. package/dist/hook-server.d.ts.map +1 -1
  62. package/dist/hook-server.js +6 -5
  63. package/dist/hook-server.js.map +1 -1
  64. package/dist/idotmatrix/idotmatrix-daemon-sync.js +2 -2
  65. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/logger.d.ts +3 -1
  70. package/dist/logger.d.ts.map +1 -1
  71. package/dist/logger.js +6 -2
  72. package/dist/logger.js.map +1 -1
  73. package/dist/model-catalog.d.ts +1 -1
  74. package/dist/model-catalog.d.ts.map +1 -1
  75. package/dist/model-catalog.js +2 -4
  76. package/dist/model-catalog.js.map +1 -1
  77. package/dist/modules/d200h-module.d.ts +65 -0
  78. package/dist/modules/d200h-module.d.ts.map +1 -0
  79. package/dist/modules/d200h-module.js +495 -0
  80. package/dist/modules/d200h-module.js.map +1 -0
  81. package/dist/modules/d200h-renderer.d.ts +7 -0
  82. package/dist/modules/d200h-renderer.d.ts.map +1 -0
  83. package/dist/modules/d200h-renderer.js +321 -0
  84. package/dist/modules/d200h-renderer.js.map +1 -0
  85. package/dist/modules/trmnl-module.d.ts +38 -0
  86. package/dist/modules/trmnl-module.d.ts.map +1 -0
  87. package/dist/modules/trmnl-module.js +111 -0
  88. package/dist/modules/trmnl-module.js.map +1 -0
  89. package/dist/observed-steering.d.ts +93 -0
  90. package/dist/observed-steering.d.ts.map +1 -0
  91. package/dist/observed-steering.js +247 -0
  92. package/dist/observed-steering.js.map +1 -0
  93. package/dist/opencode-steering.d.ts +36 -0
  94. package/dist/opencode-steering.d.ts.map +1 -0
  95. package/dist/opencode-steering.js +84 -0
  96. package/dist/opencode-steering.js.map +1 -0
  97. package/dist/output-parser.d.ts.map +1 -1
  98. package/dist/output-parser.js +48 -10
  99. package/dist/output-parser.js.map +1 -1
  100. package/dist/passive-observer.d.ts +6 -0
  101. package/dist/passive-observer.d.ts.map +1 -1
  102. package/dist/passive-observer.js +31 -1
  103. package/dist/passive-observer.js.map +1 -1
  104. package/dist/permission-resolver.d.ts +13 -5
  105. package/dist/permission-resolver.d.ts.map +1 -1
  106. package/dist/permission-resolver.js +18 -9
  107. package/dist/permission-resolver.js.map +1 -1
  108. package/dist/pixoo/micro-glyphs.d.ts +10 -41
  109. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  110. package/dist/pixoo/micro-glyphs.js +130 -263
  111. package/dist/pixoo/micro-glyphs.js.map +1 -1
  112. package/dist/pixoo/official-dot-glyphs.generated.d.ts +8 -0
  113. package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -0
  114. package/dist/pixoo/official-dot-glyphs.generated.js +247 -0
  115. package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -0
  116. package/dist/pixoo/pixoo-bridge.d.ts +14 -6
  117. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  118. package/dist/pixoo/pixoo-bridge.js +63 -39
  119. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  120. package/dist/pixoo/pixoo-renderer.d.ts +21 -2
  121. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  122. package/dist/pixoo/pixoo-renderer.js +401 -132
  123. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  124. package/dist/pixoo/pixoo-sprites.d.ts +24 -0
  125. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  126. package/dist/pixoo/pixoo-sprites.js +108 -0
  127. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  128. package/dist/review-runner.d.ts +113 -0
  129. package/dist/review-runner.d.ts.map +1 -0
  130. package/dist/review-runner.js +428 -0
  131. package/dist/review-runner.js.map +1 -0
  132. package/dist/session-aggregator.d.ts +8 -0
  133. package/dist/session-aggregator.d.ts.map +1 -1
  134. package/dist/session-aggregator.js.map +1 -1
  135. package/dist/session-focus-relay.d.ts.map +1 -1
  136. package/dist/session-focus-relay.js +7 -1
  137. package/dist/session-focus-relay.js.map +1 -1
  138. package/dist/session-registry.d.ts +3 -0
  139. package/dist/session-registry.d.ts.map +1 -1
  140. package/dist/session-registry.js +3 -0
  141. package/dist/session-registry.js.map +1 -1
  142. package/dist/timeline-store.d.ts +49 -0
  143. package/dist/timeline-store.d.ts.map +1 -1
  144. package/dist/timeline-store.js +175 -7
  145. package/dist/timeline-store.js.map +1 -1
  146. package/dist/trmnl/byos-server.d.ts +39 -0
  147. package/dist/trmnl/byos-server.d.ts.map +1 -0
  148. package/dist/trmnl/byos-server.js +301 -0
  149. package/dist/trmnl/byos-server.js.map +1 -0
  150. package/dist/trmnl/frame-cache.d.ts +37 -0
  151. package/dist/trmnl/frame-cache.d.ts.map +1 -0
  152. package/dist/trmnl/frame-cache.js +157 -0
  153. package/dist/trmnl/frame-cache.js.map +1 -0
  154. package/dist/trmnl/image-renderer.d.ts +27 -0
  155. package/dist/trmnl/image-renderer.d.ts.map +1 -0
  156. package/dist/trmnl/image-renderer.js +235 -0
  157. package/dist/trmnl/image-renderer.js.map +1 -0
  158. package/dist/trmnl/trmnl-settings.d.ts +96 -0
  159. package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
  160. package/dist/trmnl/trmnl-settings.js +177 -0
  161. package/dist/trmnl/trmnl-settings.js.map +1 -0
  162. package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
  163. package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
  164. package/dist/trmnl/trmnl-telemetry.js +56 -0
  165. package/dist/trmnl/trmnl-telemetry.js.map +1 -0
  166. package/dist/tui/dashboard.d.ts.map +1 -1
  167. package/dist/tui/dashboard.js +14 -4
  168. package/dist/tui/dashboard.js.map +1 -1
  169. package/dist/tui/renderer.d.ts.map +1 -1
  170. package/dist/tui/renderer.js +63 -13
  171. package/dist/tui/renderer.js.map +1 -1
  172. package/dist/tui/terrarium.d.ts.map +1 -1
  173. package/dist/tui/terrarium.js +7 -2
  174. package/dist/tui/terrarium.js.map +1 -1
  175. package/dist/usage-event.d.ts.map +1 -1
  176. package/dist/usage-event.js +25 -2
  177. package/dist/usage-event.js.map +1 -1
  178. package/dist/version-check.d.ts.map +1 -1
  179. package/dist/version-check.js +1 -17
  180. package/dist/version-check.js.map +1 -1
  181. package/dist/ws-server.d.ts +0 -7
  182. package/dist/ws-server.d.ts.map +1 -1
  183. package/dist/ws-server.js +5 -20
  184. package/dist/ws-server.js.map +1 -1
  185. package/package.json +4 -4
@@ -23,11 +23,22 @@ import { PassiveSessionObserver } from './passive-observer.js';
23
23
  import { SessionTimelineRelay } from './session-timeline-relay.js';
24
24
  import { SessionFocusRelay } from './session-focus-relay.js';
25
25
  import { updatePushState } from './session-aggregator.js';
26
- import { setAwaitingOverlay, clearAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved } from './awaiting-overlay.js';
27
- import { resolvePending, sweepStalePending, drainAllPending } from './permission-resolver.js';
26
+ import { setAwaitingOverlay, clearAwaitingOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved } from './awaiting-overlay.js';
27
+ import { registerPending, resolvePending, sweepStalePending, drainAllPending, isPendingRequest } from './permission-resolver.js';
28
+ import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
29
+ import { enqueueOpenCodeCommand, pollOpenCodeCommands } from './opencode-steering.js';
30
+ import { runSessionReview, reviewSnapshot } from './review-runner.js';
31
+ /** Observed-session device approval gate (PreToolUse hold). Default ON — the
32
+ * precision guards in observed-steering.ts/claude-permission-rules.ts ensure
33
+ * only genuine would-prompt calls are held. Kill switch for field issues. */
34
+ const OBSERVED_APPROVAL_ENABLED = process.env.AGENTDECK_OBSERVED_APPROVAL !== '0';
35
+ /** How long a held gate waits for a device decision before releasing the tool
36
+ * call to Claude's own permission flow. Must stay well under the hook curl's
37
+ * --max-time 60 so the release reaches Claude before curl gives up. */
38
+ const OBSERVED_APPROVAL_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGENTDECK_APPROVAL_HOLD_MS) || 25_000));
28
39
  import { VoiceManager } from './voice.js';
29
40
  import { VoiceAssistantManager } from './voice-assistant.js';
30
- import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, } from './session-registry.js';
41
+ import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
31
42
  import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
32
43
  import { isLocalConnection, validateToken } from './auth.js';
33
44
  import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
@@ -49,7 +60,7 @@ import { loadWifiConfig } from './wifi-config.js';
49
60
  import { getConnectedAdbDevices, hasAdb, getAdbDeviceCount } from './adb-reverse.js';
50
61
  import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js';
51
62
  import { loadTimeboxDevices } from './timebox/timebox-settings.js';
52
- import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail } from '@agentdeck/shared';
63
+ import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
53
64
  import { injectOpenClawSession } from './openclaw-session.js';
54
65
  import { readFileSync, statSync } from 'fs';
55
66
  import { join } from 'path';
@@ -578,7 +589,7 @@ export function enrichGatewayTimelineEntry(entry) {
578
589
  * daemon change.
579
590
  */
580
591
  export function classifyObservedHookEvent(eventName, mapped) {
581
- const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification)$/
592
+ const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
582
593
  .exec(eventName);
583
594
  if (!prefixed)
584
595
  return { boundary: mapped, agentType: 'claude-code' };
@@ -692,7 +703,7 @@ function buildNodeModuleHealth(startedModules) {
692
703
  // ===== startDaemon =====
693
704
  export async function startDaemon(opts) {
694
705
  if (opts.debug) {
695
- enableDebugLog('/tmp/agentdeck-debug.log');
706
+ enableDebugLog();
696
707
  log('[agentdeck] Debug logging enabled');
697
708
  }
698
709
  // CLI --wake-word flag OR settings.json wakeWord: true
@@ -793,6 +804,11 @@ export async function startDaemon(opts) {
793
804
  // Fallback task-row emitter, created only when initApme() returns null —
794
805
  // keeps task_start/task_end rows on the timeline without the collector.
795
806
  let fallbackTasks = null;
807
+ // Sessions that posted any hook since this daemon started — the delayed
808
+ // orphan-chat reaper must never force-close a turn belonging to a session
809
+ // that is provably alive (agentic turns regularly outlive any fixed age
810
+ // threshold). Declared before the HTTP handlers that populate it.
811
+ const hookSessionsSeen = new Set();
796
812
  // Declare early — HTTP /health handler references this in its closure.
797
813
  // Must be declared before the HTTP server so it's initialized (not in TDZ)
798
814
  // when the first /health request arrives.
@@ -1108,6 +1124,10 @@ export async function startDaemon(opts) {
1108
1124
  const message = typeof json.message === 'string' ? json.message : '';
1109
1125
  const notificationType = typeof json.notification_type === 'string' ? json.notification_type : undefined;
1110
1126
  if (isPermissionNotification(notificationType, message)) {
1127
+ // A genuine terminal prompt appeared — recent undecided gate
1128
+ // releases were real prompts, so nothing gets learned as
1129
+ // auto-approved (observed-steering learner).
1130
+ notePermissionPromptShown(claudeSid);
1111
1131
  setAwaitingOverlay(claudeSid, message);
1112
1132
  // Broadcast immediately rather than waiting for the 2s debounce
1113
1133
  // or the 5s observer tick, so the prompt surfaces within one frame.
@@ -1117,10 +1137,31 @@ export async function startDaemon(opts) {
1117
1137
  else if (mapped === 'tool_start' || mapped === 'tool_end' ||
1118
1138
  mapped === 'user_prompt_submit' || mapped === 'stop' ||
1119
1139
  mapped === 'session_start' || mapped === 'session_end') {
1140
+ // Steering lifecycle: a PostToolUse right after an undecided gate
1141
+ // release (no permission_prompt Notification in between) means
1142
+ // Claude auto-approved the call — learn the signature so it is
1143
+ // never held again this session. User prompt / session end clear
1144
+ // pending STOP + queued directives (the user took over).
1145
+ if (mapped === 'tool_end') {
1146
+ noteToolEnd(claudeSid, typeof json.tool_name === 'string' ? json.tool_name : undefined);
1147
+ }
1148
+ else if (mapped === 'user_prompt_submit') {
1149
+ if (clearOnUserPrompt(claudeSid))
1150
+ core.broadcastSessionsList().catch(() => { });
1151
+ }
1152
+ else if (mapped === 'session_end') {
1153
+ clearSteeringSession(claudeSid);
1154
+ }
1120
1155
  // Any subsequent hook means the prompt was answered — drop the
1121
1156
  // overlay. Only rebroadcast if there was actually one to clear
1122
1157
  // (direct-`claude` sessions fire tool hooks constantly).
1123
- if (clearAwaitingOverlay(claudeSid)) {
1158
+ // EXCEPT while a held gate is still pending: parallel tool calls
1159
+ // fire their own PreToolUse/PostToolUse hooks, and clearing here
1160
+ // would strip the Allow/Deny UI out from under the open request
1161
+ // (its own onResolved clears the overlay when it settles).
1162
+ const heldOverlay = getAwaitingOverlay(claudeSid);
1163
+ const gateStillPending = Boolean(heldOverlay?.requestId && isPendingRequest(heldOverlay.requestId));
1164
+ if (!gateStillPending && clearAwaitingOverlay(claudeSid)) {
1124
1165
  core.broadcastSessionsList().catch(() => { });
1125
1166
  }
1126
1167
  }
@@ -1146,6 +1187,10 @@ export async function startDaemon(opts) {
1146
1187
  // run mapping. Falls back to a stable id only when a hook omits it.
1147
1188
  const hookSid = typeof json.session_id === 'string' && json.session_id
1148
1189
  ? json.session_id : 'daemon-hook';
1190
+ // Liveness marker for the delayed orphan-chat reaper: a session that
1191
+ // posts ANY hook after daemon startup is alive, and its open turn
1192
+ // (even one persisted before the restart) must not be force-closed.
1193
+ hookSessionsSeen.add(hookSid);
1149
1194
  // Claude hooks carry `cwd` (the worktree dir), not project_name/path —
1150
1195
  // capture it so APME runs are attributable to a specific worktree.
1151
1196
  const hookCwd = (typeof json.cwd === 'string' ? json.cwd
@@ -1156,10 +1201,47 @@ export async function startDaemon(opts) {
1156
1201
  const hookMessage = json.message;
1157
1202
  // Prompt shapes: Claude `{prompt}` / `{message:{content}}`; some Codex
1158
1203
  // builds send `{user_prompt}` (same fallback chain as codex-hook.ts).
1159
- const hookPromptText = typeof json.prompt === 'string' ? json.prompt
1204
+ // Command XML envelopes (`<command-name>/merge</command-name>` …)
1205
+ // collapse to their "/merge args" form so a slash-command turn reads
1206
+ // as the command the user typed, not an XML blob.
1207
+ const hookPromptText = normalizeCommandPrompt(typeof json.prompt === 'string' ? json.prompt
1160
1208
  : (typeof hookMessage?.content === 'string' ? hookMessage.content
1161
- : (typeof json.user_prompt === 'string' ? json.user_prompt : ''));
1209
+ : (typeof json.user_prompt === 'string' ? json.user_prompt : '')));
1162
1210
  const isClearBoundary = boundary === 'user_prompt_submit' && /^\s*\/clear\s*$/i.test(hookPromptText);
1211
+ // Session ended with a turn still open (the user interrupted the turn
1212
+ // then exited, or the process was torn down before its Stop could
1213
+ // fire): close the row honestly so dashboards stop the spinner instead
1214
+ // of showing "in progress" forever. Runs BEFORE the collector so the
1215
+ // interrupted chat_end sorts above the session_end task_end row.
1216
+ // Mirrors the Swift daemon's session_end force-close (`hasOpenTurn`
1217
+ // → interrupted chat_end).
1218
+ if (boundary === 'session_end') {
1219
+ const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
1220
+ let lastStart;
1221
+ let lastCompletionTs = 0;
1222
+ for (const row of rows) {
1223
+ if (row.type === 'chat_start')
1224
+ lastStart = row;
1225
+ else if (row.type === 'chat_response' || row.type === 'chat_end') {
1226
+ lastCompletionTs = Math.max(lastCompletionTs, row.ts);
1227
+ }
1228
+ }
1229
+ if (lastStart && lastStart.ts > lastCompletionTs) {
1230
+ const now = Date.now();
1231
+ const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
1232
+ core.bridgeTimeline.addEntry({
1233
+ ts: now, type: 'chat_end',
1234
+ raw: `Interrupted · ${formatDurationSec(durS)}`,
1235
+ summaryKind: 'none',
1236
+ sessionId: hookSid,
1237
+ agentType: hookAgentType,
1238
+ startedAt: lastStart.startedAt ?? lastStart.ts,
1239
+ endedAt: now,
1240
+ ...(lastStart.taskId ? { taskId: lastStart.taskId } : {}),
1241
+ ...(hookProject ? { projectName: hookProject } : {}),
1242
+ });
1243
+ }
1244
+ }
1163
1245
  // ── APME collector (task/turn segmentation) ──
1164
1246
  if (apme) {
1165
1247
  if (isClearBoundary) {
@@ -1313,7 +1395,7 @@ export async function startDaemon(opts) {
1313
1395
  const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
1314
1396
  core.bridgeTimeline.addEntry({
1315
1397
  ts: now, type: 'chat_end',
1316
- raw: `Completed · ${durS}s`,
1398
+ raw: `Completed · ${formatDurationSec(durS)}`,
1317
1399
  summaryKind: 'none',
1318
1400
  ...base,
1319
1401
  });
@@ -1344,13 +1426,91 @@ export async function startDaemon(opts) {
1344
1426
  }
1345
1427
  }
1346
1428
  // ── Response ──
1347
- // PreToolUse returns an empty body Claude's normal permission flow
1348
- // runs untouched (zero added latency, no daemon-side gate). Every other
1349
- // event acks immediately.
1429
+ // PreToolUse and Stop are request-response steering channels (the hook
1430
+ // script echoes our body to Claude's stdout); everything else acks.
1350
1431
  if (eventName === 'PreToolUse') {
1432
+ const toolName = typeof json.tool_name === 'string' ? json.tool_name : '';
1433
+ const toolInput = (json.tool_input && typeof json.tool_input === 'object')
1434
+ ? json.tool_input : undefined;
1435
+ // 1. Soft STOP: the user pressed STOP on a device — deny this tool
1436
+ // call with a halt instruction. One-shot (flag consumed).
1437
+ if (claudeSid && consumeStop(claudeSid)) {
1438
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1439
+ res.end(JSON.stringify({
1440
+ hookSpecificOutput: {
1441
+ hookEventName: 'PreToolUse',
1442
+ permissionDecision: 'deny',
1443
+ permissionDecisionReason: STOP_DENY_REASON,
1444
+ },
1445
+ }));
1446
+ core.broadcastSessionsList().catch(() => { });
1447
+ return;
1448
+ }
1449
+ // 2. Device-approval gate: hold the response for a device decision,
1450
+ // but ONLY for calls the precision guards say Claude would
1451
+ // genuinely prompt for (see shouldHoldPreToolUse — mode gate,
1452
+ // never-prompt/prompt-prone sets, allowlist prediction, learned
1453
+ // auto-approvals, connected-client check). Anything uncertain
1454
+ // falls through to the empty pass-through below.
1455
+ if (claudeSid) {
1456
+ const gate = shouldHoldPreToolUse({
1457
+ sessionId: claudeSid,
1458
+ tool: toolName,
1459
+ toolInput,
1460
+ permissionMode: typeof json.permission_mode === 'string' ? json.permission_mode : undefined,
1461
+ cwd: hookCwd || undefined,
1462
+ clientCount: core.wsServer.getClientCount(),
1463
+ enabled: OBSERVED_APPROVAL_ENABLED,
1464
+ });
1465
+ if (gate.hold && gate.requestId) {
1466
+ const requestId = gate.requestId;
1467
+ debug('daemon', `PreToolUse gate held: ${toolName} (${gate.reason}) req=${requestId}`);
1468
+ setAwaitingOverlay(claudeSid, buildGateQuestion(toolName, toolInput), requestId);
1469
+ core.broadcastSessionsList().catch(() => { });
1470
+ registerPending(requestId, res, {
1471
+ sessionId: claudeSid,
1472
+ tool: toolName,
1473
+ timeoutMs: OBSERVED_APPROVAL_HOLD_MS,
1474
+ onResolved: (decision) => {
1475
+ // `pass` (timeout) arms the auto-approval learner: if the
1476
+ // tool then runs with no permission_prompt Notification,
1477
+ // the signature is suppressed for this session.
1478
+ gateReleased(claudeSid, requestId, {
1479
+ undecided: decision === 'pass', tool: toolName, toolInput,
1480
+ });
1481
+ clearAwaitingOverlay(claudeSid);
1482
+ core.broadcastSessionsList().catch(() => { });
1483
+ },
1484
+ });
1485
+ return; // response held open — resolved by device / timeout
1486
+ }
1487
+ }
1488
+ // Pass-through: empty body → Claude's normal permission flow runs
1489
+ // untouched (zero added latency, no daemon-side gate).
1351
1490
  res.writeHead(200, { 'Content-Type': 'application/json' });
1352
1491
  res.end('');
1353
1492
  }
1493
+ else if (eventName === 'Stop') {
1494
+ // Turn-end directive queue: deliver at most one queued deck command
1495
+ // by blocking the stop with the directive as the continuation
1496
+ // reason. Empty queue (the overwhelmingly common case) → empty body,
1497
+ // Claude ends the turn normally — no stop_hook_active loop possible.
1498
+ const directive = claudeSid ? takeDirective(claudeSid) : undefined;
1499
+ if (claudeSid)
1500
+ clearStop(claudeSid); // turn ended — a pending soft-stop is moot
1501
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1502
+ if (directive) {
1503
+ debug('daemon', `Stop hook delivering queued directive for ${claudeSid}: "${directive.slice(0, 60)}"`);
1504
+ res.end(JSON.stringify({
1505
+ decision: 'block',
1506
+ reason: `The user sent this follow-up from their AgentDeck controller: "${directive}". Carry out this instruction now.`,
1507
+ }));
1508
+ core.broadcastSessionsList().catch(() => { });
1509
+ }
1510
+ else {
1511
+ res.end('');
1512
+ }
1513
+ }
1354
1514
  else {
1355
1515
  res.writeHead(200, { 'Content-Type': 'application/json' });
1356
1516
  res.end(JSON.stringify({ received: true }));
@@ -1358,6 +1518,26 @@ export async function startDaemon(opts) {
1358
1518
  });
1359
1519
  return;
1360
1520
  }
1521
+ // OpenCode observer-plugin steering: the plugin long-polls here and
1522
+ // executes returned commands via its in-process SDK client (abort /
1523
+ // prompt). See bridge/src/opencode-steering.ts.
1524
+ if (req.method === 'GET' && pathname === '/opencode/commands') {
1525
+ const sid = parsedUrl.searchParams.get('sid') ?? '';
1526
+ if (!sid) {
1527
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1528
+ res.end(JSON.stringify({ error: 'sid required' }));
1529
+ return;
1530
+ }
1531
+ const waitMs = Math.round(Number(parsedUrl.searchParams.get('wait') ?? '25') * 1000);
1532
+ pollOpenCodeCommands(sid, waitMs).then((commands) => {
1533
+ try {
1534
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1535
+ res.end(JSON.stringify({ commands }));
1536
+ }
1537
+ catch { /* client disconnected */ }
1538
+ });
1539
+ return;
1540
+ }
1361
1541
  if (req.method === 'POST' && pathname === '/shutdown') {
1362
1542
  res.writeHead(200, { 'Content-Type': 'application/json' });
1363
1543
  res.end(JSON.stringify({ status: 'shutting_down' }));
@@ -1544,6 +1724,13 @@ export async function startDaemon(opts) {
1544
1724
  return prepareForSerial(event);
1545
1725
  });
1546
1726
  // Timeline
1727
+ //
1728
+ // The daemon owns timeline persistence: every surface's entries flow through
1729
+ // it, so it is the only process positioned to hold the complete history and
1730
+ // the only one allowed to write the file. Reads still sweep every candidate
1731
+ // data dir (`latestTimelinePath`) so a Node daemon can pick up where a Swift
1732
+ // daemon left off, and vice versa — the two implementations share one
1733
+ // on-disk format.
1547
1734
  const bridgeLogStream = new BridgeLogStream();
1548
1735
  core.wireTimeline(bridgeLogStream);
1549
1736
  const timelinePath = latestTimelinePath();
@@ -1556,7 +1743,22 @@ export async function startDaemon(opts) {
1556
1743
  const reaped = core.bridgeTimeline.reapOrphanTaskStarts();
1557
1744
  if (reaped > 0)
1558
1745
  log(`[agentdeck] Closed ${reaped} orphaned task_start rows as interrupted`);
1746
+ // Same for chat turns: a session killed mid-turn (no Stop / SessionEnd)
1747
+ // leaves its chat_start spinning "in progress" on every surface. Runs
1748
+ // DELAYED (5 min) so live sessions have posted a hook and registered in
1749
+ // `hookSessionsSeen` — only stale rows (30 min+) of sessions that stayed
1750
+ // silent since startup close; a live long-running turn is untouched and
1751
+ // its real Stop still lands normally.
1752
+ const chatReaperTimer = setTimeout(() => {
1753
+ const reapedTurns = core.bridgeTimeline.reapOrphanChatStarts(undefined, undefined, hookSessionsSeen);
1754
+ if (reapedTurns > 0)
1755
+ log(`[agentdeck] Closed ${reapedTurns} orphaned chat_start rows as interrupted`);
1756
+ }, 5 * 60_000);
1757
+ chatReaperTimer.unref?.();
1559
1758
  }
1759
+ // Enabled AFTER rehydration + reaping so the first write carries the restored
1760
+ // history instead of truncating the file to whatever this run has seen.
1761
+ core.bridgeTimeline.enablePersistence(getOwnTimelineFile());
1560
1762
  core.wireDisplayMonitor();
1561
1763
  let lastStateEvent = null;
1562
1764
  let userFocusedSessionId = null;
@@ -1718,11 +1920,12 @@ export async function startDaemon(opts) {
1718
1920
  };
1719
1921
  }
1720
1922
  const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
1721
- modules.d200h = {
1722
- connected: ulanziPluginConnected,
1723
- externalOwner: ulanziPluginConnected,
1724
- managerOpened: ulanziPluginConnected,
1725
- };
1923
+ if (ulanziPluginConnected) {
1924
+ modules.d200h = {
1925
+ connected: true,
1926
+ driver: 'ulanzi-plugin',
1927
+ };
1928
+ }
1726
1929
  return modules;
1727
1930
  };
1728
1931
  core.setModuleHealthProvider(moduleHealthProvider);
@@ -1744,9 +1947,10 @@ export async function startDaemon(opts) {
1744
1947
  events.push(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
1745
1948
  // Seed the last few timeline entries so a freshly (re)connected board's
1746
1949
  // ticker shows the real latest event instead of whatever its ring last
1747
- // held. Kept to 3 entries — the whole line must stay well under the
1748
- // small serial RX buffers on non-InkDeck boards.
1749
- const recent = core.bridgeTimeline.getHistory().slice(-3);
1950
+ // held. Kept to 6 entries — the whole line must stay well under the
1951
+ // small (4KB) serial RX buffers on non-InkDeck boards; prepareForSerial
1952
+ // byte-caps each entry's raw/detail at send time.
1953
+ const recent = core.bridgeTimeline.getHistory().slice(-6);
1750
1954
  if (recent.length > 0) {
1751
1955
  events.push({ type: 'timeline_history', entries: recent });
1752
1956
  }
@@ -1874,15 +2078,33 @@ export async function startDaemon(opts) {
1874
2078
  // the 5s-throttled observer, so a Notification arriving mid-window still
1875
2079
  // surfaces within one frame. Key = the Claude session UUID embedded in
1876
2080
  // `observed:claude:<uuid>`.
1877
- const observed = applyAwaitingOverlayToObserved(passiveSessionObserver.collect(sessions));
2081
+ const observed = applyAwaitingOverlayToObserved(passiveSessionObserver.collect(sessions))
2082
+ .map((s) => {
2083
+ // Steering feedback for observed Claude sessions: devices render
2084
+ // "stopping at next tool" / queued-directive badges from these.
2085
+ if (!s.id.startsWith('observed:claude:'))
2086
+ return s;
2087
+ const snap = steeringSnapshot(s.id.slice('observed:claude:'.length));
2088
+ if (!snap.stopRequested && snap.queuedDirectives === 0)
2089
+ return s;
2090
+ return {
2091
+ ...s,
2092
+ ...(snap.stopRequested ? { stopRequested: true } : {}),
2093
+ ...(snap.queuedDirectives > 0 ? { queuedDirectives: snap.queuedDirectives } : {}),
2094
+ };
2095
+ });
1878
2096
  // Derive per-session elapsed seconds from startedAt so NTP-less devices
1879
2097
  // (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
1880
2098
  const now = Date.now();
1881
2099
  const enrichedSessions = [...sessions, ...observed].map((s) => {
1882
- if (s.elapsedSec != null || !s.startedAt)
1883
- return s;
1884
- const sec = Math.round((now - Date.parse(s.startedAt)) / 1000);
1885
- return Number.isFinite(sec) && sec >= 0 ? { ...s, elapsedSec: sec } : s;
2100
+ // On-demand review badge (REVIEW tile verdict / REVIEWING state)
2101
+ // applies to every session type, managed included.
2102
+ const review = reviewSnapshot(s.id);
2103
+ const withReview = Object.keys(review).length > 0 ? { ...s, ...review } : s;
2104
+ if (withReview.elapsedSec != null || !withReview.startedAt)
2105
+ return withReview;
2106
+ const sec = Math.round((now - Date.parse(withReview.startedAt)) / 1000);
2107
+ return Number.isFinite(sec) && sec >= 0 ? { ...withReview, elapsedSec: sec } : withReview;
1886
2108
  });
1887
2109
  // SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
1888
2110
  // / adapter-liveness alone must not materialize a session — that kept a
@@ -2268,6 +2490,36 @@ export async function startDaemon(opts) {
2268
2490
  }
2269
2491
  return false; // not consumed — pass to command handler
2270
2492
  });
2493
+ // Steering commands for observed (hook-only, no PTY) Claude sessions —
2494
+ // shared by the session_command route and the bare-command fallback below.
2495
+ // Semantics differ from managed PTY on purpose:
2496
+ // interrupt/escape → soft STOP (deny at the next tool call)
2497
+ // send_prompt → queued, delivered when the current turn ends
2498
+ // respond/select_option → resolve an open device-approval gate
2499
+ function handleObservedClaudeCommand(uuid, command) {
2500
+ const type = typeof command?.type === 'string' ? command.type : '';
2501
+ if (type === 'interrupt' || type === 'escape') {
2502
+ requestStop(uuid);
2503
+ core.broadcastSessionsList().catch(() => { });
2504
+ return;
2505
+ }
2506
+ if (type === 'send_prompt' && typeof command.text === 'string') {
2507
+ if (queueDirective(uuid, command.text))
2508
+ core.broadcastSessionsList().catch(() => { });
2509
+ return;
2510
+ }
2511
+ if (type === 'respond' || type === 'select_option') {
2512
+ const ov = getAwaitingOverlay(uuid);
2513
+ if (ov?.requestId) {
2514
+ const allow = type === 'select_option'
2515
+ ? command.index === 0
2516
+ : command.value === 'y' || command.value === 'yes';
2517
+ resolvePending(ov.requestId, allow ? 'allow' : 'deny');
2518
+ }
2519
+ return;
2520
+ }
2521
+ debug('daemon', `observed steering: unsupported command ${type} for ${uuid}`);
2522
+ }
2271
2523
  core.wsServer.onCommand((cmd) => {
2272
2524
  debug('daemon', `cmd: ${cmd.type}`);
2273
2525
  if (gatewayAdapter?.isAlive() && gatewayAdapter.handleCommand(cmd)) {
@@ -2336,12 +2588,76 @@ export async function startDaemon(opts) {
2336
2588
  broadcastFocusedState();
2337
2589
  return;
2338
2590
  }
2591
+ // Independent on-demand review (REVIEW deck button): judge the session's
2592
+ // working-tree delta with an independent model. Valid for every session
2593
+ // type — no agent control involved. Result: WS events + badge fields +
2594
+ // HTML report opened in the browser (this daemon's "popup" tier).
2595
+ if (cmd.type === 'review_run') {
2596
+ const sessionId = cmd.sessionId;
2597
+ if (!sessionId)
2598
+ return;
2599
+ const managed = listActiveSessions().find(s => s.id === sessionId);
2600
+ const observed = managed ? undefined
2601
+ : passiveSessionObserver.collect([]).find(s => s.id === sessionId);
2602
+ const target = (managed ?? observed);
2603
+ // cwd sources: observed rows carry it from the process scan; managed
2604
+ // registry rows don't — fall back to the session's APME run projectPath.
2605
+ const cwd = (typeof target?.cwd === 'string' && target.cwd) ? target.cwd
2606
+ : (apme ? (() => {
2607
+ const runId = apme.collector.getRunId(sessionId);
2608
+ return runId ? apme.store.getRun(runId)?.projectPath ?? undefined : undefined;
2609
+ })() : undefined);
2610
+ // Recent request↔response lines so the judge evaluates the diff
2611
+ // against what the user actually asked (not just code in isolation).
2612
+ const recentActivity = core.bridgeTimeline.getHistoryForSession(sessionId, undefined, 16)
2613
+ .filter((e) => e.type === 'chat_start' || e.type === 'chat_response')
2614
+ .map((e) => `${e.type === 'chat_start' ? 'USER' : 'AGENT'}: ${e.raw}`)
2615
+ .join('\n');
2616
+ void runSessionReview({
2617
+ sessionId,
2618
+ cwd,
2619
+ projectName: target?.projectName ?? cwd?.split('/').filter(Boolean).pop() ?? 'unknown',
2620
+ recentActivity,
2621
+ onEvent: (event) => {
2622
+ core.wsServer.broadcast(event);
2623
+ core.broadcastSessionsList().catch(() => { }); // badge refresh
2624
+ },
2625
+ recordEval: apme ? (record) => {
2626
+ // Record into the same eval store as the automatic pipeline,
2627
+ // flagged `manual_review`, on the session's active task (if any).
2628
+ const runId = apme.collector.getRunId(sessionId);
2629
+ const taskId = apme.collector.getActiveTaskId(sessionId);
2630
+ if (!runId || !taskId)
2631
+ return;
2632
+ apme.store.insertEvalForTask({
2633
+ runId, taskId, layer: 'manual_review', metric: 'risk',
2634
+ score: record.score, raw: record.raw,
2635
+ judgeModel: record.judgeModel, createdAt: Date.now(),
2636
+ });
2637
+ } : undefined,
2638
+ });
2639
+ core.broadcastSessionsList().catch(() => { }); // REVIEWING tile
2640
+ return;
2641
+ }
2339
2642
  // Device approval for a gated PreToolUse permission request (observed session).
2340
2643
  // Resolves the held hook HTTP response → Claude allows/denies the tool.
2341
2644
  if (cmd.type === 'permission_decision') {
2342
2645
  const { requestId, decision } = cmd;
2343
2646
  if (typeof requestId !== 'string' || (decision !== 'allow' && decision !== 'deny'))
2344
2647
  return;
2648
+ // OpenCode permission (requestId = "ocperm:<rawSid>:<permissionID>"):
2649
+ // route through the observer-plugin queue; the plugin resolves it via
2650
+ // POST /session/{id}/permissions/{permissionID}.
2651
+ if (requestId.startsWith('ocperm:')) {
2652
+ const rest = requestId.slice('ocperm:'.length);
2653
+ const sep = rest.indexOf(':');
2654
+ if (sep > 0) {
2655
+ enqueueOpenCodeCommand(rest.slice(0, sep), {
2656
+ type: 'permission_respond', permissionId: rest.slice(sep + 1), response: decision,
2657
+ });
2658
+ }
2659
+ return;
2660
+ }
2345
2661
  // resolvePending → onResolved clears the overlay + rebroadcasts (sessions_list
2346
2662
  // + focused state), so all surfaces drop the gate. No-op if already resolved.
2347
2663
  resolvePending(requestId, decision);
@@ -2352,6 +2668,26 @@ export async function startDaemon(opts) {
2352
2668
  const { sessionId, command } = cmd;
2353
2669
  if (!sessionId || !command)
2354
2670
  return;
2671
+ // Observed Claude sessions have no bridge/PTY — route to the hook-based
2672
+ // steering primitives instead (soft STOP, turn-end directive queue,
2673
+ // gate approval). This replaces the old silent drop.
2674
+ if (typeof sessionId === 'string' && sessionId.startsWith('observed:claude:')) {
2675
+ handleObservedClaudeCommand(sessionId.slice('observed:claude:'.length), command);
2676
+ return;
2677
+ }
2678
+ // Observed OpenCode sessions steer directly through the observer
2679
+ // plugin's command queue (immediate abort / prompt injection).
2680
+ if (typeof sessionId === 'string' && (sessionId.startsWith('opencode:') || sessionId.startsWith('observed:opencode:'))) {
2681
+ const rawSid = sessionId.replace(/^(?:observed:)?opencode:/, '');
2682
+ const type = typeof command?.type === 'string' ? command.type : '';
2683
+ if (type === 'interrupt' || type === 'escape') {
2684
+ enqueueOpenCodeCommand(rawSid, { type: 'interrupt' });
2685
+ }
2686
+ else if (type === 'send_prompt' && typeof command.text === 'string') {
2687
+ enqueueOpenCodeCommand(rawSid, { type: 'send_prompt', text: command.text });
2688
+ }
2689
+ return;
2690
+ }
2355
2691
  const sessions = listActiveSessions();
2356
2692
  const target = sessions.find(s => s.id === sessionId);
2357
2693
  if (!target) {
@@ -2384,6 +2720,15 @@ export async function startDaemon(opts) {
2384
2720
  if (focusRelay.getFocusedSessionId() && focusRelay.routeCommand(cmd)) {
2385
2721
  return;
2386
2722
  }
2723
+ // Bare-command fallback for an observed-focused session: older device
2724
+ // clients send unwrapped commands after focus_session. The focus relay
2725
+ // can't route them (no bridge), so map them to the steering primitives
2726
+ // instead of dropping them silently.
2727
+ if (userFocusedSessionId?.startsWith('observed:claude:')
2728
+ && ['interrupt', 'escape', 'send_prompt', 'respond', 'select_option'].includes(cmd.type)) {
2729
+ handleObservedClaudeCommand(userFocusedSessionId.slice('observed:claude:'.length), cmd);
2730
+ return;
2731
+ }
2387
2732
  if (cmd.type === 'query_usage') {
2388
2733
  fetchUsageRelayed(port).then((usage) => {
2389
2734
  if (usage)
@@ -2617,6 +2962,9 @@ export async function startDaemon(opts) {
2617
2962
  voiceAssistant?.stop();
2618
2963
  voiceManager?.disconnectFromServer();
2619
2964
  bridgeLogStream.stop();
2965
+ // Flush synchronously — the process exits a few lines below and a pending
2966
+ // debounce timer would take the last turn's entries with it.
2967
+ core.bridgeTimeline.stopPersistence();
2620
2968
  // iDotMatrix BLE sync is stopped by IDotMatrixModule.stop() via stopModules below.
2621
2969
  await Promise.all([
2622
2970
  gatewayAdapter ? gatewayAdapter.shutdown().catch(() => { }) : Promise.resolve(),