@agentdeck/bridge 1.1.0 → 1.2.1

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 (199) hide show
  1. package/README.md +14 -6
  2. package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
  3. package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
  4. package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
  5. package/assets/fonts/pocket-sans-world.manifest.json +49 -0
  6. package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
  7. package/assets/learning/NOTICE.md +33 -0
  8. package/assets/learning/jp-n3-ko.manifest.json +27 -0
  9. package/assets/learning/jp-n3-ko.pdl +0 -0
  10. package/dist/agent-cli-diagnostics.d.ts +20 -0
  11. package/dist/agent-cli-diagnostics.d.ts.map +1 -0
  12. package/dist/agent-cli-diagnostics.js +100 -0
  13. package/dist/agent-cli-diagnostics.js.map +1 -0
  14. package/dist/apme/classifier.d.ts.map +1 -1
  15. package/dist/apme/classifier.js +18 -10
  16. package/dist/apme/classifier.js.map +1 -1
  17. package/dist/apme/claude-transcript-reader.d.ts +36 -0
  18. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  19. package/dist/apme/claude-transcript-reader.js +78 -2
  20. package/dist/apme/claude-transcript-reader.js.map +1 -1
  21. package/dist/apme/collector.d.ts +126 -3
  22. package/dist/apme/collector.d.ts.map +1 -1
  23. package/dist/apme/collector.js +450 -41
  24. package/dist/apme/collector.js.map +1 -1
  25. package/dist/apme/dashboard-html.d.ts.map +1 -1
  26. package/dist/apme/dashboard-html.js +278 -36
  27. package/dist/apme/dashboard-html.js.map +1 -1
  28. package/dist/apme/graph.d.ts +1 -1
  29. package/dist/apme/graph.d.ts.map +1 -1
  30. package/dist/apme/graph.js +51 -1
  31. package/dist/apme/graph.js.map +1 -1
  32. package/dist/apme/http.d.ts.map +1 -1
  33. package/dist/apme/http.js +37 -5
  34. package/dist/apme/http.js.map +1 -1
  35. package/dist/apme/index.d.ts +4 -0
  36. package/dist/apme/index.d.ts.map +1 -1
  37. package/dist/apme/index.js +19 -3
  38. package/dist/apme/index.js.map +1 -1
  39. package/dist/apme/runner.d.ts +90 -0
  40. package/dist/apme/runner.d.ts.map +1 -1
  41. package/dist/apme/runner.js +524 -48
  42. package/dist/apme/runner.js.map +1 -1
  43. package/dist/apme/sample-to-timeline.d.ts.map +1 -1
  44. package/dist/apme/sample-to-timeline.js +5 -0
  45. package/dist/apme/sample-to-timeline.js.map +1 -1
  46. package/dist/apme/settings.d.ts +8 -0
  47. package/dist/apme/settings.d.ts.map +1 -1
  48. package/dist/apme/settings.js +4 -0
  49. package/dist/apme/settings.js.map +1 -1
  50. package/dist/apme/store.d.ts +118 -9
  51. package/dist/apme/store.d.ts.map +1 -1
  52. package/dist/apme/store.js +342 -44
  53. package/dist/apme/store.js.map +1 -1
  54. package/dist/apme/task-gradeability.d.ts +82 -0
  55. package/dist/apme/task-gradeability.d.ts.map +1 -0
  56. package/dist/apme/task-gradeability.js +126 -0
  57. package/dist/apme/task-gradeability.js.map +1 -0
  58. package/dist/awaiting-overlay.d.ts +13 -26
  59. package/dist/awaiting-overlay.d.ts.map +1 -1
  60. package/dist/awaiting-overlay.js +7 -40
  61. package/dist/awaiting-overlay.js.map +1 -1
  62. package/dist/bridge-core.d.ts +15 -0
  63. package/dist/bridge-core.d.ts.map +1 -1
  64. package/dist/bridge-core.js +30 -4
  65. package/dist/bridge-core.js.map +1 -1
  66. package/dist/claude-permission-rules.d.ts +20 -32
  67. package/dist/claude-permission-rules.d.ts.map +1 -1
  68. package/dist/claude-permission-rules.js +19 -102
  69. package/dist/claude-permission-rules.js.map +1 -1
  70. package/dist/claude-usage-recovery.d.ts +54 -0
  71. package/dist/claude-usage-recovery.d.ts.map +1 -0
  72. package/dist/claude-usage-recovery.js +200 -0
  73. package/dist/claude-usage-recovery.js.map +1 -0
  74. package/dist/cli.d.ts +11 -0
  75. package/dist/cli.d.ts.map +1 -1
  76. package/dist/cli.js +81 -14
  77. package/dist/cli.js.map +1 -1
  78. package/dist/codex-rate-limits-live.d.ts +180 -10
  79. package/dist/codex-rate-limits-live.d.ts.map +1 -1
  80. package/dist/codex-rate-limits-live.js +475 -22
  81. package/dist/codex-rate-limits-live.js.map +1 -1
  82. package/dist/codex-rollout-response.d.ts +43 -0
  83. package/dist/codex-rollout-response.d.ts.map +1 -1
  84. package/dist/codex-rollout-response.js +67 -0
  85. package/dist/codex-rollout-response.js.map +1 -1
  86. package/dist/coordination-evidence.d.ts +150 -0
  87. package/dist/coordination-evidence.d.ts.map +1 -0
  88. package/dist/coordination-evidence.js +455 -0
  89. package/dist/coordination-evidence.js.map +1 -0
  90. package/dist/daemon-server.d.ts.map +1 -1
  91. package/dist/daemon-server.js +592 -35
  92. package/dist/daemon-server.js.map +1 -1
  93. package/dist/daemon-ws-client.d.ts +1 -0
  94. package/dist/daemon-ws-client.d.ts.map +1 -1
  95. package/dist/daemon-ws-client.js +25 -16
  96. package/dist/daemon-ws-client.js.map +1 -1
  97. package/dist/daemon.js +1 -1
  98. package/dist/deferred-reply-read.d.ts +24 -0
  99. package/dist/deferred-reply-read.d.ts.map +1 -0
  100. package/dist/deferred-reply-read.js +42 -0
  101. package/dist/deferred-reply-read.js.map +1 -0
  102. package/dist/esp32-serial.d.ts +15 -0
  103. package/dist/esp32-serial.d.ts.map +1 -1
  104. package/dist/esp32-serial.js +61 -2
  105. package/dist/esp32-serial.js.map +1 -1
  106. package/dist/font-pack.d.ts +15 -0
  107. package/dist/font-pack.d.ts.map +1 -0
  108. package/dist/font-pack.js +75 -0
  109. package/dist/font-pack.js.map +1 -0
  110. package/dist/glance-frame.d.ts.map +1 -1
  111. package/dist/glance-frame.js +2 -0
  112. package/dist/glance-frame.js.map +1 -1
  113. package/dist/hook-codex-sessions.d.ts +9 -0
  114. package/dist/hook-codex-sessions.d.ts.map +1 -1
  115. package/dist/hook-codex-sessions.js +35 -2
  116. package/dist/hook-codex-sessions.js.map +1 -1
  117. package/dist/hook-opencode-sessions.d.ts +62 -0
  118. package/dist/hook-opencode-sessions.d.ts.map +1 -0
  119. package/dist/hook-opencode-sessions.js +180 -0
  120. package/dist/hook-opencode-sessions.js.map +1 -0
  121. package/dist/hook-response-source.d.ts +40 -0
  122. package/dist/hook-response-source.d.ts.map +1 -0
  123. package/dist/hook-response-source.js +31 -0
  124. package/dist/hook-response-source.js.map +1 -0
  125. package/dist/http-auth-gate.d.ts.map +1 -1
  126. package/dist/http-auth-gate.js +9 -1
  127. package/dist/http-auth-gate.js.map +1 -1
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +6 -3
  130. package/dist/index.js.map +1 -1
  131. package/dist/learning-pack.d.ts +24 -0
  132. package/dist/learning-pack.d.ts.map +1 -0
  133. package/dist/learning-pack.js +125 -0
  134. package/dist/learning-pack.js.map +1 -0
  135. package/dist/mdns.js +1 -1
  136. package/dist/mdns.js.map +1 -1
  137. package/dist/native-dependency-diagnostics.d.ts +29 -0
  138. package/dist/native-dependency-diagnostics.d.ts.map +1 -0
  139. package/dist/native-dependency-diagnostics.js +95 -0
  140. package/dist/native-dependency-diagnostics.js.map +1 -0
  141. package/dist/observed-steering.d.ts +4 -4
  142. package/dist/observed-steering.d.ts.map +1 -1
  143. package/dist/observed-steering.js +28 -32
  144. package/dist/observed-steering.js.map +1 -1
  145. package/dist/openclaw-gateway-stability.d.ts +94 -0
  146. package/dist/openclaw-gateway-stability.d.ts.map +1 -0
  147. package/dist/openclaw-gateway-stability.js +176 -0
  148. package/dist/openclaw-gateway-stability.js.map +1 -0
  149. package/dist/pairing-knocks.d.ts +80 -0
  150. package/dist/pairing-knocks.d.ts.map +1 -0
  151. package/dist/pairing-knocks.js +312 -0
  152. package/dist/pairing-knocks.js.map +1 -0
  153. package/dist/passive-observer.d.ts +5 -0
  154. package/dist/passive-observer.d.ts.map +1 -1
  155. package/dist/passive-observer.js +6 -0
  156. package/dist/passive-observer.js.map +1 -1
  157. package/dist/pixoo/micro-glyphs.d.ts +4 -0
  158. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  159. package/dist/pixoo/micro-glyphs.js +29 -0
  160. package/dist/pixoo/micro-glyphs.js.map +1 -1
  161. package/dist/pixoo/pixoo-font.d.ts.map +1 -1
  162. package/dist/pixoo/pixoo-font.js +3 -1
  163. package/dist/pixoo/pixoo-font.js.map +1 -1
  164. package/dist/pixoo/pixoo-renderer.d.ts +1 -1
  165. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  166. package/dist/pixoo/pixoo-renderer.js +17 -2
  167. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  168. package/dist/pty-manager.d.ts +9 -0
  169. package/dist/pty-manager.d.ts.map +1 -1
  170. package/dist/pty-manager.js +63 -4
  171. package/dist/pty-manager.js.map +1 -1
  172. package/dist/relayed-usage.d.ts +30 -0
  173. package/dist/relayed-usage.d.ts.map +1 -1
  174. package/dist/relayed-usage.js +25 -2
  175. package/dist/relayed-usage.js.map +1 -1
  176. package/dist/review-runner.js +1 -1
  177. package/dist/subagent-timeline.d.ts +27 -0
  178. package/dist/subagent-timeline.d.ts.map +1 -1
  179. package/dist/subagent-timeline.js +52 -5
  180. package/dist/subagent-timeline.js.map +1 -1
  181. package/dist/surface-protocol.d.ts.map +1 -1
  182. package/dist/surface-protocol.js +2 -0
  183. package/dist/surface-protocol.js.map +1 -1
  184. package/dist/tui/dashboard.d.ts.map +1 -1
  185. package/dist/tui/dashboard.js +8 -1
  186. package/dist/tui/dashboard.js.map +1 -1
  187. package/dist/usage-api.d.ts +3 -0
  188. package/dist/usage-api.d.ts.map +1 -1
  189. package/dist/usage-api.js +101 -36
  190. package/dist/usage-api.js.map +1 -1
  191. package/dist/usage-event.d.ts.map +1 -1
  192. package/dist/usage-event.js +2 -1
  193. package/dist/usage-event.js.map +1 -1
  194. package/dist/ws-server.d.ts +18 -0
  195. package/dist/ws-server.d.ts.map +1 -1
  196. package/dist/ws-server.js +40 -2
  197. package/dist/ws-server.js.map +1 -1
  198. package/package.json +6 -5
  199. package/src/timebox/sync_ble.py +35 -16
@@ -18,9 +18,10 @@ import { mkdirSync, writeFileSync } from 'fs';
18
18
  import { homedir } from 'os';
19
19
  import { debug } from '../logger.js';
20
20
  import { resolveProjectName } from '../utils/project-name.js';
21
- import { isPricedModel, normalizeModelProvider, priceUsd } from '@agentdeck/shared';
21
+ import { AGENT_IDLE_GAP_MS, deriveTaskTitle, isPricedModel, normalizeModelProvider, priceUsd } from '@agentdeck/shared';
22
22
  import { classifyRunSmart, computeSignals, classify } from './classifier.js';
23
- import { readOpenTurnEvidence } from './claude-transcript-reader.js';
23
+ import { readOpenTurnEvidence, claudeTurnCompletionSince } from './claude-transcript-reader.js';
24
+ import { codexTurnCompletionSince } from '../codex-rollout-response.js';
24
25
  /** A turn_start with the same prompt landing on a fresh, still-empty turn
25
26
  * within this window is treated as a transport echo (e.g. OpenClaw
26
27
  * `chat.send` span + the gateway's `session.message` role=user re-delivery
@@ -32,6 +33,10 @@ const DUPLICATE_TURN_OPEN_WINDOW_MS = 15_000;
32
33
  * a cancel milliseconds into a turn reads as belonging to the previous one.
33
34
  * Matches the watchdog's `TURN_OPEN_SLACK_MS` — same comparison, same risk. */
34
35
  const TURN_INTERRUPT_SLACK_MS = 2_000;
36
+ /** How long after a turn closed a late-arriving reply may still be attached
37
+ * to it once the run itself is gone from memory — the deferred transcript
38
+ * re-read tops out at 7.5 s; a minute leaves room for a slow disk. */
39
+ const LATE_REPLY_WINDOW_MS = 60_000;
35
40
  /**
36
41
  * Is `echo` the same user prompt as `original`, arriving a second time?
37
42
  *
@@ -67,6 +72,9 @@ export class ApmeCollector {
67
72
  store;
68
73
  hwSampler;
69
74
  openTurnProbe;
75
+ idleGapMs;
76
+ codexCompletionProbe;
77
+ claudeCompletionProbe;
70
78
  sessionToRun = new Map(); // sessionId → runId
71
79
  sessionToAgentType = new Map(); // sessionId → agentType (survives closeRun for late-attributed timeline rows)
72
80
  sessionToTurn = new Map(); // sessionId → current turn
@@ -96,14 +104,195 @@ export class ApmeCollector {
96
104
  /** Last milestone key (`taskId:turnIndex`) per session — a turn can carry
97
105
  * several all-completed TodoWrite calls; surface only the first. */
98
106
  sessionToLastMilestone = new Map();
107
+ /** Per-session idle-gap timers — armed in `closeTurn`, cleared on the next
108
+ * turn open / task close. See `AGENT_IDLE_GAP_MS` for the measurement the
109
+ * constant rests on. */
110
+ sessionToIdleGapTimer = new Map();
111
+ /** Sessions whose run was re-adopted from the store at startup — lets a
112
+ * newer open run for the same session replace the older one during the
113
+ * same rehydrate pass without touching runs opened live since. */
114
+ rehydratedSessions = new Set();
99
115
  constructor(store, hwSampler,
100
116
  /** What the transcript proves about a turn open since a timestamp — cancel,
101
117
  * client abort, or never having run at all. Injectable for tests; the
102
118
  * default reads the Claude transcript tail once for all three. */
103
- openTurnProbe = readOpenTurnEvidence) {
119
+ openTurnProbe = readOpenTurnEvidence,
120
+ /** Injectable for tests only — production call sites take the default. */
121
+ idleGapMs = AGENT_IDLE_GAP_MS,
122
+ /** Whether a Codex turn open at startup completed per its rollout — see
123
+ * `rehydrateOpenRuns`. Injectable for tests; the default reads the
124
+ * rollout tail under `~/.codex/sessions`. */
125
+ codexCompletionProbe = codexTurnCompletionSince,
126
+ /** Whether a Claude turn open at startup ended per its transcript — the
127
+ * Claude half of the same question. Injectable for tests; the default
128
+ * locates the transcript under `~/.claude/projects` by session id. */
129
+ claudeCompletionProbe = claudeTurnCompletionSince) {
104
130
  this.store = store;
105
131
  this.hwSampler = hwSampler;
106
132
  this.openTurnProbe = openTurnProbe;
133
+ this.idleGapMs = idleGapMs;
134
+ this.codexCompletionProbe = codexCompletionProbe;
135
+ this.claudeCompletionProbe = claudeCompletionProbe;
136
+ }
137
+ /** Re-adopt every run the store still holds open, so the daemon that just
138
+ * started continues them instead of stranding them.
139
+ *
140
+ * The collector's whole state is in-memory maps, and a daemon restart drops
141
+ * all of it while the sessions it was observing carry on. Before this, the
142
+ * next hook from such a session found no run, opened a fresh one, and the
143
+ * old one sat open until the abandoned-run reaper closed it two hours
144
+ * later — its task `orphaned`, its open turn's tool counters at zero, and
145
+ * the Stop that did arrive discarded for want of a turn to close. Measured
146
+ * over 5.5 days (2026-09-03) on a dev machine that restarted the daemon 40
147
+ * times: 60 of the 68 tasks the reaper closed straddled a restart, and the
148
+ * idle-gap boundary that had just been added could not fire for any of
149
+ * them, since its timer lived in the process that died.
150
+ *
151
+ * Per open run, in store order: the session→run edge is restored; the open
152
+ * task (if any) is rebuilt with its real turn span and first prompt; an
153
+ * open turn is rebuilt with its counters recovered from the trajectory it
154
+ * wrote; and when there is no open turn but a closed one, the idle-gap
155
+ * timer is armed for whatever remains of the gap — zero if it already
156
+ * elapsed during the downtime, which closes the task on the next tick as
157
+ * the `idle_gap` it always was. A Codex turn still open is checked once
158
+ * against its rollout: a `task_complete` newer than the turn is the Stop
159
+ * this daemon never received, and closes it as `synthetic_stop` with the
160
+ * reply the rollout recorded.
161
+ *
162
+ * Two runs open under one session id (an older defect's residue) resolve
163
+ * to the newest; the rest stay for the reaper. Must run before the first
164
+ * hook is served, and only in the process that owns the hook port — a
165
+ * session bridge adopting the daemon's runs would steal them. */
166
+ rehydrateOpenRuns(now = Date.now()) {
167
+ const out = { runs: 0, tasks: 0, turns: 0, armed: 0, recovered: 0 };
168
+ if (!this.store.enabled)
169
+ return out;
170
+ let runs;
171
+ try {
172
+ runs = this.store.listOpenRuns();
173
+ }
174
+ catch (err) {
175
+ debug('APME', `rehydrate: listOpenRuns failed: ${String(err)}`);
176
+ return out;
177
+ }
178
+ for (const run of runs) {
179
+ // Newest run per session wins; store order is oldest-first, so a later
180
+ // row simply replaces the mapping an earlier one set.
181
+ if (this.sessionToRun.has(run.sessionId) && !this.rehydratedSessions.has(run.sessionId))
182
+ continue;
183
+ this.rehydratedSessions.add(run.sessionId);
184
+ this.sessionToRun.set(run.sessionId, run.id);
185
+ this.sessionToAgentType.set(run.sessionId, run.agentType);
186
+ out.runs++;
187
+ let turns = [];
188
+ let tasks = [];
189
+ try {
190
+ turns = this.store.listTurns(run.id);
191
+ tasks = this.store.listTasksForRun(run.id);
192
+ }
193
+ catch (err) {
194
+ debug('APME', `rehydrate: run ${run.id.slice(0, 8)} unreadable: ${String(err)}`);
195
+ continue;
196
+ }
197
+ this.runTaskCount.set(run.id, tasks.length);
198
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
199
+ if (lastTurn)
200
+ this.sessionToLastTurnId.set(run.sessionId, String(lastTurn.id));
201
+ const openTask = tasks.find((t) => t.endedAt == null) ?? null;
202
+ let activeTask = null;
203
+ if (openTask) {
204
+ const taskTurns = turns.filter((t) => t.task_id === openTask.id);
205
+ const first = taskTurns[0];
206
+ const firstIndex = openTask.firstTurnIndex ?? (first ? Number(first.turn_index) : null);
207
+ const lastIndex = taskTurns.length > 0
208
+ ? Number(taskTurns[taskTurns.length - 1].turn_index)
209
+ : (openTask.lastTurnIndex ?? firstIndex);
210
+ activeTask = {
211
+ id: openTask.id,
212
+ runId: run.id,
213
+ index: openTask.taskIndex,
214
+ startedAt: openTask.startedAt,
215
+ firstTurnIndex: firstIndex,
216
+ lastTurnIndex: lastIndex,
217
+ firstPrompt: typeof first?.prompt === 'string' ? first.prompt : null,
218
+ // The header is promoted on the second turn; a task that already
219
+ // has two either emitted it or is owed it — either way the closure
220
+ // row must be allowed to render. Single-turn tasks stay deferred.
221
+ timelineEmitted: taskTurns.length >= 2,
222
+ };
223
+ this.sessionToTask.set(run.sessionId, activeTask);
224
+ out.tasks++;
225
+ }
226
+ this.sessionToUsage.delete(run.sessionId);
227
+ const openTurn = lastTurn && lastTurn.ended_at == null ? lastTurn : null;
228
+ if (openTurn) {
229
+ const index = Number(openTurn.turn_index);
230
+ const counters = activeTask
231
+ ? this.store.countToolEventsForTurn(activeTask.id, index)
232
+ : { toolCalls: 0, filesModified: 0, filesCreated: 0 };
233
+ this.sessionToTurn.set(run.sessionId, {
234
+ id: String(openTurn.id),
235
+ runId: run.id,
236
+ index,
237
+ startedAt: Number(openTurn.started_at),
238
+ toolCalls: counters.toolCalls,
239
+ filesModified: counters.filesModified,
240
+ filesCreated: counters.filesCreated,
241
+ gitBefore: typeof openTurn.git_before === 'string' ? openTurn.git_before : null,
242
+ prompt: typeof openTurn.prompt === 'string' ? openTurn.prompt : null,
243
+ hasResponse: typeof openTurn.response === 'string' && openTurn.response.length > 0,
244
+ });
245
+ out.turns++;
246
+ // The agent's own record says whether the Stop this turn was owed
247
+ // ever happened. Closed at the RECORD's time, not now: the daemon was
248
+ // down in between, and that gap is nobody's turn duration.
249
+ if (run.agentType === 'codex-cli') {
250
+ let completion = null;
251
+ try {
252
+ completion = this.codexCompletionProbe(run.sessionId, Number(openTurn.started_at));
253
+ }
254
+ catch {
255
+ completion = null;
256
+ }
257
+ if (completion) {
258
+ if (completion.text)
259
+ this.setTurnResponse(run.sessionId, completion.text);
260
+ // A turn Codex itself failed (usage limit, dead endpoint) fires no
261
+ // Stop and is not a dropped hook.
262
+ const source = completion.error ? 'aborted' : 'synthetic_stop';
263
+ this.closeTurn(run.sessionId, source, completion.completedAt);
264
+ out.recovered++;
265
+ debug('APME', `rehydrate: codex turn ${String(openTurn.id).slice(0, 8)} ${completion.error ? 'failed' : 'completed'} at ${new Date(completion.completedAt).toISOString()} per rollout — closed as ${source}`);
266
+ }
267
+ }
268
+ else if (run.agentType === 'claude-code') {
269
+ let completion = null;
270
+ try {
271
+ completion = this.claudeCompletionProbe(run.sessionId, run.projectPath ?? null, Number(openTurn.started_at));
272
+ }
273
+ catch {
274
+ completion = null;
275
+ }
276
+ if (completion) {
277
+ if (completion.text)
278
+ this.setTurnResponse(run.sessionId, completion.text);
279
+ this.closeTurn(run.sessionId, completion.source, completion.endedAt);
280
+ out.recovered++;
281
+ debug('APME', `rehydrate: claude turn ${String(openTurn.id).slice(0, 8)} ended at ${new Date(completion.endedAt).toISOString()} per transcript — closed as ${completion.source}`);
282
+ }
283
+ }
284
+ }
285
+ else if (activeTask && lastTurn) {
286
+ const lastEnd = Number(lastTurn.ended_at);
287
+ const remaining = Math.max(0, this.idleGapMs - Math.max(0, now - lastEnd));
288
+ this.armIdleGapTimer(run.sessionId, remaining);
289
+ out.armed++;
290
+ }
291
+ }
292
+ if (out.runs > 0) {
293
+ debug('APME', `rehydrated ${out.runs} open run(s): ${out.tasks} task(s), ${out.turns} open turn(s), ${out.armed} idle timer(s) re-armed, ${out.recovered} turn(s) closed from the agent's own record`);
294
+ }
295
+ return out;
107
296
  }
108
297
  /** Start a new run and return its id. Safe to call if store disabled (returns ''). */
109
298
  openRun(input) {
@@ -150,6 +339,7 @@ export class ApmeCollector {
150
339
  const runId = this.sessionToRun.get(sessionId);
151
340
  if (!runId)
152
341
  return;
342
+ this.heard(sessionId);
153
343
  const event = normalizeHookEventName(rawEvent);
154
344
  const toolName = typeof data.tool_name === 'string' ? data.tool_name : null;
155
345
  // ── Turn management ──
@@ -190,7 +380,10 @@ export class ApmeCollector {
190
380
  // and no watchdog recovered it either, or the synthetic Stop would have
191
381
  // closed it already. That is the unrecovered-loss bucket, UNLESS the
192
382
  // user cancelled it, in which case no Stop was ever owed.
193
- this.closeTurn(sessionId, this.resolveDisplacedTurnSource(sessionId, data));
383
+ const displaced = this.resolveDisplacedTurn(sessionId, data);
384
+ if (displaced.response)
385
+ this.setTurnResponse(sessionId, displaced.response);
386
+ this.closeTurn(sessionId, displaced.source, displaced.endedAt);
194
387
  // Open new turn
195
388
  const turnIndex = prevIndex + 1;
196
389
  const run = this.store.getRun(runId);
@@ -204,14 +397,18 @@ export class ApmeCollector {
204
397
  prompt, hasResponse: false,
205
398
  };
206
399
  this.sessionToTurn.set(sessionId, turn);
400
+ // A new turn is the "still working" signal the idle-gap timer waits on.
401
+ this.clearIdleGapTimer(sessionId);
207
402
  // Ensure an active task exists so this turn can attach to it. Tasks group
208
403
  // consecutive turns between boundary signals (TodoWrite all-completed,
209
404
  // /clear, session_end). First turn in a run opens task 0.
210
405
  const task = this.openTaskIfNone(sessionId, runId);
211
406
  if (task) {
212
407
  const wasFirstTurn = task.firstTurnIndex === null;
213
- if (wasFirstTurn)
408
+ if (wasFirstTurn) {
214
409
  task.firstTurnIndex = turnIndex;
410
+ task.firstPrompt = prompt ?? null;
411
+ }
215
412
  task.lastTurnIndex = turnIndex;
216
413
  // Second (or later) turn on the same task proves it's a real,
217
414
  // multi-turn work unit → promote the deferred TASK header so the
@@ -357,6 +554,7 @@ export class ApmeCollector {
357
554
  noteTurnStop(sessionId, opts = {}) {
358
555
  if (!this.store.enabled)
359
556
  return;
557
+ this.heard(sessionId);
360
558
  // `interrupted` and `aborted` both outrank `synthetic`: neither ending
361
559
  // produces a real Stop, so each can only ever arrive AS a synthetic one,
362
560
  // and reading the flags the other way round would file every user cancel
@@ -391,26 +589,47 @@ export class ApmeCollector {
391
589
  * model turn and owes exactly one Stop, which closes the LAST of them.
392
590
  * A displaced row with no assistant record behind it at all is that
393
591
  * artifact, not a lost hook. */
394
- resolveDisplacedTurnSource(sessionId, data) {
592
+ resolveDisplacedTurn(sessionId, data) {
395
593
  const turn = this.sessionToTurn.get(sessionId);
396
594
  // No open turn: closeTurn no-ops, so the value is immaterial.
397
595
  if (!turn)
398
- return 'next_prompt';
399
- // Claude-only: the marker and the JSONL shape are Claude Code's.
400
- if (this.sessionToAgentType.get(sessionId) !== 'claude-code')
401
- return 'next_prompt';
596
+ return { source: 'next_prompt' };
597
+ const agentType = this.sessionToAgentType.get(sessionId);
598
+ // Codex: the rollout is the record. Measured over one week (2026-09-03),
599
+ // 23 codex turns reached the next prompt open; the rollout held a
600
+ // completed reply for 16 of them (the Stop alone was lost), a failure for
601
+ // 2 (a dead endpoint — no Stop is ever fired for those), and nothing for
602
+ // 5. Only the last five were dropped hooks; the rest are recovered here
603
+ // with the reply and Codex's own end time, the way the watchdog recovers
604
+ // a Claude turn.
605
+ if (agentType === 'codex-cli') {
606
+ try {
607
+ const completion = this.codexCompletionProbe(sessionId, turn.startedAt - TURN_INTERRUPT_SLACK_MS);
608
+ if (!completion)
609
+ return { source: 'next_prompt' };
610
+ if (completion.error)
611
+ return { source: 'aborted', endedAt: completion.completedAt };
612
+ return { source: 'synthetic_stop', endedAt: completion.completedAt, response: completion.text || undefined };
613
+ }
614
+ catch {
615
+ return { source: 'next_prompt' };
616
+ }
617
+ }
618
+ // Claude: the marker and the JSONL shape are Claude Code's.
619
+ if (agentType !== 'claude-code')
620
+ return { source: 'next_prompt' };
402
621
  const transcriptPath = typeof data.transcript_path === 'string' ? data.transcript_path : '';
403
622
  if (!transcriptPath)
404
- return 'next_prompt';
623
+ return { source: 'next_prompt' };
405
624
  try {
406
625
  const evidence = this.openTurnProbe(transcriptPath, turn.startedAt - TURN_INTERRUPT_SLACK_MS);
407
626
  // An unreadable transcript is no evidence of anything — never guess.
408
627
  if (!evidence)
409
- return 'next_prompt';
628
+ return { source: 'next_prompt' };
410
629
  if (evidence.interruptedAt != null)
411
- return 'interrupted';
630
+ return { source: 'interrupted' };
412
631
  if (evidence.abortedAt != null)
413
- return 'aborted';
632
+ return { source: 'aborted' };
414
633
  // Ordered last on purpose: a cancelled or aborted turn that also wrote
415
634
  // nothing is still a cancel or an abort, not a superseded prompt.
416
635
  //
@@ -422,17 +641,17 @@ export class ApmeCollector {
422
641
  // truncate the walk and hide the assistant work behind it. A turn that
423
642
  // called tools demonstrably ran whatever the transcript says.
424
643
  if (!evidence.sawAssistant && turn.toolCalls === 0)
425
- return 'superseded';
426
- return 'next_prompt';
644
+ return { source: 'superseded' };
645
+ return { source: 'next_prompt' };
427
646
  }
428
647
  catch {
429
- return 'next_prompt';
648
+ return { source: 'next_prompt' };
430
649
  }
431
650
  }
432
651
  /** Close the current turn for a session (called on Stop, new prompt, or
433
652
  * session end). `source` records which of those it was — see
434
653
  * `TurnEndSource`. */
435
- closeTurn(sessionId, source) {
654
+ closeTurn(sessionId, source, endedAt = Date.now()) {
436
655
  const turn = this.sessionToTurn.get(sessionId);
437
656
  if (!turn)
438
657
  return;
@@ -443,7 +662,7 @@ export class ApmeCollector {
443
662
  const gitAfter = readGitHead(projectPath);
444
663
  try {
445
664
  this.store.updateTurn(turn.id, {
446
- endedAt: Date.now(),
665
+ endedAt,
447
666
  toolCalls: turn.toolCalls,
448
667
  filesModified: turn.filesModified,
449
668
  filesCreated: turn.filesCreated,
@@ -455,6 +674,12 @@ export class ApmeCollector {
455
674
  catch (err) {
456
675
  debug('APME', `closeTurn failed: ${String(err)}`);
457
676
  }
677
+ // Idle starts when the turn ends — at the turn's OWN end time, so a turn
678
+ // closed from the agent's record after the fact (rehydrate, next-prompt
679
+ // recovery) is already as idle as the record says it is. The timer is
680
+ // cleared by the next turn open (still working) or by closeTask
681
+ // (boundary reached another way).
682
+ this.armIdleGapTimer(sessionId, Math.max(0, this.idleGapMs - Math.max(0, Date.now() - endedAt)));
458
683
  }
459
684
  /** Get the current active turn ID for a session (if any). */
460
685
  getActiveTurnId(sessionId) {
@@ -519,6 +744,7 @@ export class ApmeCollector {
519
744
  startedAt: Date.now(),
520
745
  firstTurnIndex: null,
521
746
  lastTurnIndex: null,
747
+ firstPrompt: null,
522
748
  timelineEmitted: false,
523
749
  };
524
750
  this.sessionToTask.set(sessionId, task);
@@ -559,12 +785,40 @@ export class ApmeCollector {
559
785
  projectName: run?.projectName ?? null,
560
786
  taskIndex: task.index,
561
787
  startedAt: task.startedAt,
788
+ title: deriveTaskTitle(task.firstPrompt),
562
789
  });
563
790
  }
564
791
  catch (err) {
565
792
  debug('APME', `onTaskOpened listener threw: ${String(err)}`);
566
793
  }
567
794
  }
795
+ /** Arm the per-session idle-gap timer. Called from `closeTurn` — the turn
796
+ * just finished, so the gap being measured is genuine idle time, never the
797
+ * agent's own working time. Re-arming replaces any previous timer. */
798
+ armIdleGapTimer(sessionId, delayMs = this.idleGapMs) {
799
+ this.clearIdleGapTimer(sessionId);
800
+ const timer = setTimeout(() => {
801
+ this.sessionToIdleGapTimer.delete(sessionId);
802
+ // A turn opened since (and its clear lost a race), or work is mid-turn:
803
+ // never split under an active turn.
804
+ if (this.sessionToTurn.has(sessionId))
805
+ return;
806
+ if (!this.sessionToTask.has(sessionId))
807
+ return;
808
+ debug('APME', `idle gap (${Math.round(this.idleGapMs / 1000)}s) → closing task for ${sessionId.slice(0, 8)}`);
809
+ this.closeTask(sessionId, 'idle_gap');
810
+ }, delayMs);
811
+ // Never hold the process open for an idle bookkeeping timer.
812
+ timer.unref?.();
813
+ this.sessionToIdleGapTimer.set(sessionId, timer);
814
+ }
815
+ clearIdleGapTimer(sessionId) {
816
+ const timer = this.sessionToIdleGapTimer.get(sessionId);
817
+ if (timer) {
818
+ clearTimeout(timer);
819
+ this.sessionToIdleGapTimer.delete(sessionId);
820
+ }
821
+ }
568
822
  /** Public wrapper for closeTask. Used by the manual-boundary CLI /
569
823
  * HTTP route + the macOS detail-pane button — lets the user declare
570
824
  * "this task is done" without `/clear` (which would also split the
@@ -597,6 +851,7 @@ export class ApmeCollector {
597
851
  return;
598
852
  this.sessionToTask.delete(sessionId);
599
853
  this.sessionToLastMilestone.delete(sessionId);
854
+ this.clearIdleGapTimer(sessionId);
600
855
  // Empty task: no turns ever attached. Drop the row so the dashboard
601
856
  // doesn't show phantom entries from back-to-back boundary signals.
602
857
  if (task.firstTurnIndex === null) {
@@ -688,11 +943,39 @@ export class ApmeCollector {
688
943
  * whose user simply stepped away mid-turn, and the row would then be closed
689
944
  * underneath the collector that is still writing to it. */
690
945
  isLiveRun(runId) {
691
- for (const id of this.sessionToRun.values())
692
- if (id === runId)
693
- return true;
946
+ for (const [sessionId, id] of this.sessionToRun) {
947
+ if (id !== runId)
948
+ continue;
949
+ // Adoption at startup is PROVISIONAL: a run re-adopted from the store
950
+ // whose session has not sent a single hook since is owned in name only.
951
+ // Counting it live forever meant a session that ended while the daemon
952
+ // was down could never be reaped — its run had no Stop coming, no idle
953
+ // timer (those arm on turn close), and a reaper that deferred to us.
954
+ // Measured 2026-09-03: two such runs open 17 hours after the restart.
955
+ return !this.rehydratedSessions.has(sessionId);
956
+ }
694
957
  return false;
695
958
  }
959
+ /** Drop every in-memory edge to `runId` after the reaper closed it in the
960
+ * store, so a hook that arrives later opens a fresh run instead of writing
961
+ * turns into a row that is already finalised. */
962
+ releaseRun(runId) {
963
+ for (const [sessionId, id] of [...this.sessionToRun]) {
964
+ if (id !== runId)
965
+ continue;
966
+ this.clearIdleGapTimer(sessionId);
967
+ this.sessionToRun.delete(sessionId);
968
+ this.sessionToTask.delete(sessionId);
969
+ this.sessionToTurn.delete(sessionId);
970
+ this.sessionToLastTurnId.delete(sessionId);
971
+ this.rehydratedSessions.delete(sessionId);
972
+ }
973
+ this.runTaskCount.delete(runId);
974
+ }
975
+ /** The session spoke: an adopted run is now owned for real. */
976
+ heard(sessionId) {
977
+ this.rehydratedSessions.delete(sessionId);
978
+ }
696
979
  /** Agent type established for a session at `openRun` (survives closeRun so a
697
980
  * late-attributed timeline row still resolves its brand). This is the single
698
981
  * authoritative agentType source the timeline attributor backfills from, so
@@ -780,6 +1063,126 @@ export class ApmeCollector {
780
1063
  }
781
1064
  }
782
1065
  }
1066
+ /** Persist a child-agent lifecycle event on its parent task. Child hooks are
1067
+ * deliberately consumed before ordinary APME ingestion so they cannot
1068
+ * mutate parent state or controls; SubagentTimelineTracker explicitly
1069
+ * hands just this observation-safe evidence back to the collector.
1070
+ *
1071
+ * This is the producer for SampleModelConfig.subagents. The richer event
1072
+ * also powers the judge rollup and Graph node without promoting a child to
1073
+ * a separately steerable session. Returns false when no parent task is
1074
+ * active, which is honest: there is no task edge to guess. */
1075
+ noteSubagentLifecycle(sessionId, event) {
1076
+ if (!this.store.enabled)
1077
+ return false;
1078
+ const ctx = this.sampleCtxForTurn(sessionId);
1079
+ if (!ctx)
1080
+ return false;
1081
+ const task = this.store.getTask(ctx.taskId);
1082
+ if (!task)
1083
+ return false;
1084
+ const name = event.name.trim();
1085
+ if (!name)
1086
+ return false;
1087
+ try {
1088
+ this.store.updateTask(ctx.taskId, {
1089
+ modelConfig: this.mergedTaskModelConfig(task, undefined, undefined, name),
1090
+ });
1091
+ }
1092
+ catch { /* keep the lifecycle event even if the header update failed */ }
1093
+ const durationMs = event.startedAt == null
1094
+ ? null
1095
+ : Math.max(0, event.ts - event.startedAt);
1096
+ this.appendSampleEvent(ctx, {
1097
+ kind: 'subagent',
1098
+ ts: event.ts,
1099
+ dedupCore: `${event.phase}:${event.id}`,
1100
+ payloadObj: {
1101
+ id: event.id,
1102
+ name,
1103
+ phase: event.phase,
1104
+ ...(event.summary ? { summary: event.summary } : {}),
1105
+ ...(durationMs == null ? {} : { durationMs }),
1106
+ },
1107
+ });
1108
+ return true;
1109
+ }
1110
+ /** Persist a free-form annotation on the session's active task (a task-list
1111
+ * item checked off, a team event without a child identity). Returns false
1112
+ * when no task is open — there is nothing honest to attach it to. */
1113
+ noteInfo(sessionId, event) {
1114
+ if (!this.store.enabled)
1115
+ return false;
1116
+ const label = event.label.trim();
1117
+ if (!label)
1118
+ return false;
1119
+ const ctx = this.sampleCtxForTurn(sessionId);
1120
+ if (!ctx)
1121
+ return false;
1122
+ const ts = event.ts ?? Date.now();
1123
+ this.appendSampleEvent(ctx, {
1124
+ kind: 'info',
1125
+ ts,
1126
+ dedupCore: `${label}:${ts}:${event.detail ?? ''}`,
1127
+ payloadObj: { label, ...(event.detail ? { detail: event.detail } : {}) },
1128
+ });
1129
+ return true;
1130
+ }
1131
+ /** Persist a cross-session coordination observation (`RelationEvent`) on
1132
+ * the session's active task. Like child lifecycle, this is evidence for the
1133
+ * work board and the collaboration lens, never a steerable session or a
1134
+ * parent link guessed from project membership: the producer
1135
+ * (`CoordinationTracker`) only calls this with what a process table, a
1136
+ * SendMessage tool call or a cross-session envelope actually said. */
1137
+ noteRelation(sessionId, event) {
1138
+ if (!this.store.enabled)
1139
+ return false;
1140
+ const ctx = this.sampleCtxForTurn(sessionId);
1141
+ if (!ctx)
1142
+ return false;
1143
+ const ts = event.ts ?? Date.now();
1144
+ this.appendSampleEvent(ctx, {
1145
+ kind: 'relation',
1146
+ ts,
1147
+ dedupCore: `${event.relation}:${event.direction}:${event.phase}:${event.key}`,
1148
+ payloadObj: {
1149
+ relation: event.relation,
1150
+ direction: event.direction,
1151
+ phase: event.phase,
1152
+ ...(event.peerSessionId ? { peerSessionId: event.peerSessionId } : {}),
1153
+ ...(event.peerName ? { peerName: event.peerName } : {}),
1154
+ evidence: event.evidence,
1155
+ ...(event.detail ? { detail: event.detail } : {}),
1156
+ },
1157
+ });
1158
+ return true;
1159
+ }
1160
+ /** Merge instead of replacing the sample identity header. Model updates can
1161
+ * arrive after subagent starts (and vice versa); overwriting model_config
1162
+ * here was why a future subagents producer would have appeared to work and
1163
+ * then silently vanished on the next usage/model hook. */
1164
+ mergedTaskModelConfig(task, modelId, provider, subagent) {
1165
+ let previous = {};
1166
+ if (task.modelConfig) {
1167
+ try {
1168
+ const parsed = JSON.parse(task.modelConfig);
1169
+ if (parsed && typeof parsed === 'object')
1170
+ previous = parsed;
1171
+ }
1172
+ catch { /* malformed legacy value: rebuild from row columns */ }
1173
+ }
1174
+ const subagents = new Set(Array.isArray(previous.subagents)
1175
+ ? previous.subagents.filter((v) => typeof v === 'string' && v.length > 0)
1176
+ : []);
1177
+ if (subagent)
1178
+ subagents.add(subagent);
1179
+ return JSON.stringify({
1180
+ ...previous,
1181
+ modelId: modelId ?? task.modelId ?? previous.modelId ?? 'unknown',
1182
+ provider: provider === undefined ? (task.provider ?? previous.provider ?? null) : provider,
1183
+ ...(subagents.size > 0 ? { subagents: [...subagents].sort() } : {}),
1184
+ });
1185
+ }
783
1186
  /** Reverse-lookup sessionId for a runId (best-effort; only the live map). */
784
1187
  runToSessionId(runId) {
785
1188
  for (const [sid, rid] of this.sessionToRun)
@@ -853,7 +1256,16 @@ export class ApmeCollector {
853
1256
  setLastClosedTurnResponse(sessionId, response) {
854
1257
  if (!this.store.enabled)
855
1258
  return;
856
- const turnId = this.sessionToLastTurnId.get(sessionId);
1259
+ // The in-memory edge is dropped at closeRun, and a one-turn session
1260
+ // (`claude -p`, a spawned worker) sends its SessionEnd 80–130 ms after
1261
+ // its Stop — before the deferred transcript re-read (1.5 s) that exists
1262
+ // because the Stop's own read found nothing. The reply then arrived to
1263
+ // no turn and was dropped: 6 of 13 such sessions in one day (2026-09-03).
1264
+ // The store still knows the turn; a bounded window keeps a late reply
1265
+ // from landing on a session's turn from an earlier life.
1266
+ const turnId = this.sessionToLastTurnId.get(sessionId)
1267
+ ?? this.store.latestClosedTurnIdForSession(sessionId, Date.now() - LATE_REPLY_WINDOW_MS)
1268
+ ?? undefined;
857
1269
  if (!turnId)
858
1270
  return;
859
1271
  const existing = this.store.getTurn(turnId);
@@ -1135,14 +1547,16 @@ export class ApmeCollector {
1135
1547
  ?? run?.provider
1136
1548
  ?? normalizeModelProvider(undefined, model);
1137
1549
  const taskRow = this.store.getTask(task.id);
1550
+ if (!taskRow)
1551
+ return;
1138
1552
  const taskModel = !model
1139
- ? taskRow?.modelId
1140
- : !taskRow?.modelId || taskRow.modelId === model
1553
+ ? taskRow.modelId
1554
+ : !taskRow.modelId || taskRow.modelId === model
1141
1555
  ? model
1142
1556
  : 'mixed';
1143
1557
  const taskProvider = !provider || provider === 'unknown'
1144
- ? taskRow?.provider
1145
- : !taskRow?.provider || taskRow.provider === provider
1558
+ ? taskRow.provider
1559
+ : !taskRow.provider || taskRow.provider === provider
1146
1560
  ? provider
1147
1561
  : 'mixed';
1148
1562
  const turnIndex = this.sessionToTurn.get(sessionId)?.index ?? task.lastTurnIndex ?? 0;
@@ -1155,10 +1569,7 @@ export class ApmeCollector {
1155
1569
  this.store.updateTask(task.id, {
1156
1570
  modelId: taskModel,
1157
1571
  provider: taskProvider,
1158
- modelConfig: JSON.stringify({
1159
- modelId: taskModel ?? 'unknown',
1160
- provider: taskProvider ?? null,
1161
- }),
1572
+ modelConfig: this.mergedTaskModelConfig(taskRow, taskModel ?? 'unknown', taskProvider ?? null),
1162
1573
  });
1163
1574
  this.store.recomputeSampleCost(task.id);
1164
1575
  }
@@ -1205,16 +1616,14 @@ export class ApmeCollector {
1205
1616
  updateModel(sessionId, modelId) {
1206
1617
  if (!this.store.enabled || !modelId)
1207
1618
  return;
1208
- const runId = this.sessionToRun.get(sessionId);
1209
- if (!runId)
1619
+ if (!this.sessionToRun.has(sessionId))
1210
1620
  return;
1211
- try {
1212
- this.store.updateRun(runId, {
1213
- modelId,
1214
- provider: normalizeModelProvider(undefined, modelId),
1215
- });
1216
- }
1217
- catch { /* ignore */ }
1621
+ // The TURN is the scorecard's identity source (`turns.model_id`), and
1622
+ // this used to write the run only — so every hook-observed Claude and
1623
+ // Codex turn carried NULL there (465 + 183 in one week, 2026-09-03) and
1624
+ // the per-model scorecard ranked nothing. The run keeps its
1625
+ // latest-observed value for legacy readers.
1626
+ this.updateTurnIdentity(sessionId, modelId);
1218
1627
  }
1219
1628
  /** Persist the model/provider on the assistant message's own turn.
1220
1629
  * `session_meta` can arrive after `chat.final` closed that turn, so the same
@@ -1272,7 +1681,7 @@ export class ApmeCollector {
1272
1681
  this.store.updateTask(taskId, {
1273
1682
  modelId: taskModel,
1274
1683
  provider: taskProvider,
1275
- modelConfig: JSON.stringify({ modelId: taskModel, provider: taskProvider }),
1684
+ modelConfig: this.mergedTaskModelConfig(task, taskModel, taskProvider),
1276
1685
  });
1277
1686
  }
1278
1687
  catch { /* ignore */ }