@agentdeck/bridge 1.2.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 (123) hide show
  1. package/dist/apme/classifier.d.ts.map +1 -1
  2. package/dist/apme/classifier.js +18 -10
  3. package/dist/apme/classifier.js.map +1 -1
  4. package/dist/apme/claude-transcript-reader.d.ts +36 -0
  5. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  6. package/dist/apme/claude-transcript-reader.js +78 -2
  7. package/dist/apme/claude-transcript-reader.js.map +1 -1
  8. package/dist/apme/collector.d.ts +109 -3
  9. package/dist/apme/collector.d.ts.map +1 -1
  10. package/dist/apme/collector.js +408 -44
  11. package/dist/apme/collector.js.map +1 -1
  12. package/dist/apme/dashboard-html.d.ts.map +1 -1
  13. package/dist/apme/dashboard-html.js +4 -2
  14. package/dist/apme/dashboard-html.js.map +1 -1
  15. package/dist/apme/graph.d.ts +1 -1
  16. package/dist/apme/graph.d.ts.map +1 -1
  17. package/dist/apme/graph.js +51 -1
  18. package/dist/apme/graph.js.map +1 -1
  19. package/dist/apme/http.d.ts.map +1 -1
  20. package/dist/apme/http.js +4 -0
  21. package/dist/apme/http.js.map +1 -1
  22. package/dist/apme/index.d.ts +4 -0
  23. package/dist/apme/index.d.ts.map +1 -1
  24. package/dist/apme/index.js +13 -1
  25. package/dist/apme/index.js.map +1 -1
  26. package/dist/apme/runner.d.ts +90 -0
  27. package/dist/apme/runner.d.ts.map +1 -1
  28. package/dist/apme/runner.js +524 -48
  29. package/dist/apme/runner.js.map +1 -1
  30. package/dist/apme/sample-to-timeline.d.ts.map +1 -1
  31. package/dist/apme/sample-to-timeline.js +5 -0
  32. package/dist/apme/sample-to-timeline.js.map +1 -1
  33. package/dist/apme/settings.d.ts +8 -0
  34. package/dist/apme/settings.d.ts.map +1 -1
  35. package/dist/apme/settings.js +4 -0
  36. package/dist/apme/settings.js.map +1 -1
  37. package/dist/apme/store.d.ts +77 -8
  38. package/dist/apme/store.d.ts.map +1 -1
  39. package/dist/apme/store.js +189 -17
  40. package/dist/apme/store.js.map +1 -1
  41. package/dist/apme/task-gradeability.d.ts +82 -0
  42. package/dist/apme/task-gradeability.d.ts.map +1 -0
  43. package/dist/apme/task-gradeability.js +126 -0
  44. package/dist/apme/task-gradeability.js.map +1 -0
  45. package/dist/awaiting-overlay.d.ts +13 -26
  46. package/dist/awaiting-overlay.d.ts.map +1 -1
  47. package/dist/awaiting-overlay.js +7 -40
  48. package/dist/awaiting-overlay.js.map +1 -1
  49. package/dist/claude-permission-rules.d.ts +20 -32
  50. package/dist/claude-permission-rules.d.ts.map +1 -1
  51. package/dist/claude-permission-rules.js +19 -102
  52. package/dist/claude-permission-rules.js.map +1 -1
  53. package/dist/claude-usage-recovery.d.ts +54 -0
  54. package/dist/claude-usage-recovery.d.ts.map +1 -0
  55. package/dist/claude-usage-recovery.js +200 -0
  56. package/dist/claude-usage-recovery.js.map +1 -0
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +28 -8
  59. package/dist/cli.js.map +1 -1
  60. package/dist/codex-rollout-response.d.ts +43 -0
  61. package/dist/codex-rollout-response.d.ts.map +1 -1
  62. package/dist/codex-rollout-response.js +67 -0
  63. package/dist/codex-rollout-response.js.map +1 -1
  64. package/dist/coordination-evidence.d.ts +150 -0
  65. package/dist/coordination-evidence.d.ts.map +1 -0
  66. package/dist/coordination-evidence.js +455 -0
  67. package/dist/coordination-evidence.js.map +1 -0
  68. package/dist/daemon-server.d.ts.map +1 -1
  69. package/dist/daemon-server.js +329 -34
  70. package/dist/daemon-server.js.map +1 -1
  71. package/dist/daemon.js +1 -1
  72. package/dist/deferred-reply-read.d.ts +24 -0
  73. package/dist/deferred-reply-read.d.ts.map +1 -0
  74. package/dist/deferred-reply-read.js +42 -0
  75. package/dist/deferred-reply-read.js.map +1 -0
  76. package/dist/esp32-serial.d.ts +11 -0
  77. package/dist/esp32-serial.d.ts.map +1 -1
  78. package/dist/esp32-serial.js +57 -2
  79. package/dist/esp32-serial.js.map +1 -1
  80. package/dist/hook-codex-sessions.d.ts +9 -0
  81. package/dist/hook-codex-sessions.d.ts.map +1 -1
  82. package/dist/hook-codex-sessions.js +35 -2
  83. package/dist/hook-codex-sessions.js.map +1 -1
  84. package/dist/hook-opencode-sessions.d.ts +62 -0
  85. package/dist/hook-opencode-sessions.d.ts.map +1 -0
  86. package/dist/hook-opencode-sessions.js +180 -0
  87. package/dist/hook-opencode-sessions.js.map +1 -0
  88. package/dist/hook-response-source.d.ts +40 -0
  89. package/dist/hook-response-source.d.ts.map +1 -0
  90. package/dist/hook-response-source.js +31 -0
  91. package/dist/hook-response-source.js.map +1 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +4 -1
  94. package/dist/index.js.map +1 -1
  95. package/dist/native-dependency-diagnostics.d.ts +29 -0
  96. package/dist/native-dependency-diagnostics.d.ts.map +1 -0
  97. package/dist/native-dependency-diagnostics.js +95 -0
  98. package/dist/native-dependency-diagnostics.js.map +1 -0
  99. package/dist/observed-steering.d.ts +4 -4
  100. package/dist/observed-steering.d.ts.map +1 -1
  101. package/dist/observed-steering.js +28 -32
  102. package/dist/observed-steering.js.map +1 -1
  103. package/dist/openclaw-gateway-stability.d.ts +94 -0
  104. package/dist/openclaw-gateway-stability.d.ts.map +1 -0
  105. package/dist/openclaw-gateway-stability.js +176 -0
  106. package/dist/openclaw-gateway-stability.js.map +1 -0
  107. package/dist/passive-observer.d.ts +5 -0
  108. package/dist/passive-observer.d.ts.map +1 -1
  109. package/dist/passive-observer.js +6 -0
  110. package/dist/passive-observer.js.map +1 -1
  111. package/dist/review-runner.js +1 -1
  112. package/dist/subagent-timeline.d.ts +27 -0
  113. package/dist/subagent-timeline.d.ts.map +1 -1
  114. package/dist/subagent-timeline.js +52 -5
  115. package/dist/subagent-timeline.js.map +1 -1
  116. package/dist/usage-api.d.ts +3 -0
  117. package/dist/usage-api.d.ts.map +1 -1
  118. package/dist/usage-api.js +101 -36
  119. package/dist/usage-api.js.map +1 -1
  120. package/dist/usage-event.d.ts.map +1 -1
  121. package/dist/usage-event.js +2 -1
  122. package/dist/usage-event.js.map +1 -1
  123. package/package.json +5 -5
@@ -20,7 +20,8 @@ import { debug } from '../logger.js';
20
20
  import { resolveProjectName } from '../utils/project-name.js';
21
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
  *
@@ -68,6 +73,8 @@ export class ApmeCollector {
68
73
  hwSampler;
69
74
  openTurnProbe;
70
75
  idleGapMs;
76
+ codexCompletionProbe;
77
+ claudeCompletionProbe;
71
78
  sessionToRun = new Map(); // sessionId → runId
72
79
  sessionToAgentType = new Map(); // sessionId → agentType (survives closeRun for late-attributed timeline rows)
73
80
  sessionToTurn = new Map(); // sessionId → current turn
@@ -101,17 +108,191 @@ export class ApmeCollector {
101
108
  * turn open / task close. See `AGENT_IDLE_GAP_MS` for the measurement the
102
109
  * constant rests on. */
103
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();
104
115
  constructor(store, hwSampler,
105
116
  /** What the transcript proves about a turn open since a timestamp — cancel,
106
117
  * client abort, or never having run at all. Injectable for tests; the
107
118
  * default reads the Claude transcript tail once for all three. */
108
119
  openTurnProbe = readOpenTurnEvidence,
109
120
  /** Injectable for tests only — production call sites take the default. */
110
- idleGapMs = AGENT_IDLE_GAP_MS) {
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) {
111
130
  this.store = store;
112
131
  this.hwSampler = hwSampler;
113
132
  this.openTurnProbe = openTurnProbe;
114
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;
115
296
  }
116
297
  /** Start a new run and return its id. Safe to call if store disabled (returns ''). */
117
298
  openRun(input) {
@@ -158,6 +339,7 @@ export class ApmeCollector {
158
339
  const runId = this.sessionToRun.get(sessionId);
159
340
  if (!runId)
160
341
  return;
342
+ this.heard(sessionId);
161
343
  const event = normalizeHookEventName(rawEvent);
162
344
  const toolName = typeof data.tool_name === 'string' ? data.tool_name : null;
163
345
  // ── Turn management ──
@@ -198,7 +380,10 @@ export class ApmeCollector {
198
380
  // and no watchdog recovered it either, or the synthetic Stop would have
199
381
  // closed it already. That is the unrecovered-loss bucket, UNLESS the
200
382
  // user cancelled it, in which case no Stop was ever owed.
201
- 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);
202
387
  // Open new turn
203
388
  const turnIndex = prevIndex + 1;
204
389
  const run = this.store.getRun(runId);
@@ -369,6 +554,7 @@ export class ApmeCollector {
369
554
  noteTurnStop(sessionId, opts = {}) {
370
555
  if (!this.store.enabled)
371
556
  return;
557
+ this.heard(sessionId);
372
558
  // `interrupted` and `aborted` both outrank `synthetic`: neither ending
373
559
  // produces a real Stop, so each can only ever arrive AS a synthetic one,
374
560
  // and reading the flags the other way round would file every user cancel
@@ -403,26 +589,47 @@ export class ApmeCollector {
403
589
  * model turn and owes exactly one Stop, which closes the LAST of them.
404
590
  * A displaced row with no assistant record behind it at all is that
405
591
  * artifact, not a lost hook. */
406
- resolveDisplacedTurnSource(sessionId, data) {
592
+ resolveDisplacedTurn(sessionId, data) {
407
593
  const turn = this.sessionToTurn.get(sessionId);
408
594
  // No open turn: closeTurn no-ops, so the value is immaterial.
409
595
  if (!turn)
410
- return 'next_prompt';
411
- // Claude-only: the marker and the JSONL shape are Claude Code's.
412
- if (this.sessionToAgentType.get(sessionId) !== 'claude-code')
413
- 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' };
414
621
  const transcriptPath = typeof data.transcript_path === 'string' ? data.transcript_path : '';
415
622
  if (!transcriptPath)
416
- return 'next_prompt';
623
+ return { source: 'next_prompt' };
417
624
  try {
418
625
  const evidence = this.openTurnProbe(transcriptPath, turn.startedAt - TURN_INTERRUPT_SLACK_MS);
419
626
  // An unreadable transcript is no evidence of anything — never guess.
420
627
  if (!evidence)
421
- return 'next_prompt';
628
+ return { source: 'next_prompt' };
422
629
  if (evidence.interruptedAt != null)
423
- return 'interrupted';
630
+ return { source: 'interrupted' };
424
631
  if (evidence.abortedAt != null)
425
- return 'aborted';
632
+ return { source: 'aborted' };
426
633
  // Ordered last on purpose: a cancelled or aborted turn that also wrote
427
634
  // nothing is still a cancel or an abort, not a superseded prompt.
428
635
  //
@@ -434,17 +641,17 @@ export class ApmeCollector {
434
641
  // truncate the walk and hide the assistant work behind it. A turn that
435
642
  // called tools demonstrably ran whatever the transcript says.
436
643
  if (!evidence.sawAssistant && turn.toolCalls === 0)
437
- return 'superseded';
438
- return 'next_prompt';
644
+ return { source: 'superseded' };
645
+ return { source: 'next_prompt' };
439
646
  }
440
647
  catch {
441
- return 'next_prompt';
648
+ return { source: 'next_prompt' };
442
649
  }
443
650
  }
444
651
  /** Close the current turn for a session (called on Stop, new prompt, or
445
652
  * session end). `source` records which of those it was — see
446
653
  * `TurnEndSource`. */
447
- closeTurn(sessionId, source) {
654
+ closeTurn(sessionId, source, endedAt = Date.now()) {
448
655
  const turn = this.sessionToTurn.get(sessionId);
449
656
  if (!turn)
450
657
  return;
@@ -455,7 +662,7 @@ export class ApmeCollector {
455
662
  const gitAfter = readGitHead(projectPath);
456
663
  try {
457
664
  this.store.updateTurn(turn.id, {
458
- endedAt: Date.now(),
665
+ endedAt,
459
666
  toolCalls: turn.toolCalls,
460
667
  filesModified: turn.filesModified,
461
668
  filesCreated: turn.filesCreated,
@@ -467,9 +674,12 @@ export class ApmeCollector {
467
674
  catch (err) {
468
675
  debug('APME', `closeTurn failed: ${String(err)}`);
469
676
  }
470
- // Idle starts when the turn ends. The timer is cleared by the next turn
471
- // open (still working) or by closeTask (boundary reached another way).
472
- this.armIdleGapTimer(sessionId);
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)));
473
683
  }
474
684
  /** Get the current active turn ID for a session (if any). */
475
685
  getActiveTurnId(sessionId) {
@@ -585,7 +795,7 @@ export class ApmeCollector {
585
795
  /** Arm the per-session idle-gap timer. Called from `closeTurn` — the turn
586
796
  * just finished, so the gap being measured is genuine idle time, never the
587
797
  * agent's own working time. Re-arming replaces any previous timer. */
588
- armIdleGapTimer(sessionId) {
798
+ armIdleGapTimer(sessionId, delayMs = this.idleGapMs) {
589
799
  this.clearIdleGapTimer(sessionId);
590
800
  const timer = setTimeout(() => {
591
801
  this.sessionToIdleGapTimer.delete(sessionId);
@@ -597,7 +807,7 @@ export class ApmeCollector {
597
807
  return;
598
808
  debug('APME', `idle gap (${Math.round(this.idleGapMs / 1000)}s) → closing task for ${sessionId.slice(0, 8)}`);
599
809
  this.closeTask(sessionId, 'idle_gap');
600
- }, this.idleGapMs);
810
+ }, delayMs);
601
811
  // Never hold the process open for an idle bookkeeping timer.
602
812
  timer.unref?.();
603
813
  this.sessionToIdleGapTimer.set(sessionId, timer);
@@ -733,11 +943,39 @@ export class ApmeCollector {
733
943
  * whose user simply stepped away mid-turn, and the row would then be closed
734
944
  * underneath the collector that is still writing to it. */
735
945
  isLiveRun(runId) {
736
- for (const id of this.sessionToRun.values())
737
- if (id === runId)
738
- 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
+ }
739
957
  return false;
740
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
+ }
741
979
  /** Agent type established for a session at `openRun` (survives closeRun so a
742
980
  * late-attributed timeline row still resolves its brand). This is the single
743
981
  * authoritative agentType source the timeline attributor backfills from, so
@@ -825,6 +1063,126 @@ export class ApmeCollector {
825
1063
  }
826
1064
  }
827
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
+ }
828
1186
  /** Reverse-lookup sessionId for a runId (best-effort; only the live map). */
829
1187
  runToSessionId(runId) {
830
1188
  for (const [sid, rid] of this.sessionToRun)
@@ -898,7 +1256,16 @@ export class ApmeCollector {
898
1256
  setLastClosedTurnResponse(sessionId, response) {
899
1257
  if (!this.store.enabled)
900
1258
  return;
901
- 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;
902
1269
  if (!turnId)
903
1270
  return;
904
1271
  const existing = this.store.getTurn(turnId);
@@ -1180,14 +1547,16 @@ export class ApmeCollector {
1180
1547
  ?? run?.provider
1181
1548
  ?? normalizeModelProvider(undefined, model);
1182
1549
  const taskRow = this.store.getTask(task.id);
1550
+ if (!taskRow)
1551
+ return;
1183
1552
  const taskModel = !model
1184
- ? taskRow?.modelId
1185
- : !taskRow?.modelId || taskRow.modelId === model
1553
+ ? taskRow.modelId
1554
+ : !taskRow.modelId || taskRow.modelId === model
1186
1555
  ? model
1187
1556
  : 'mixed';
1188
1557
  const taskProvider = !provider || provider === 'unknown'
1189
- ? taskRow?.provider
1190
- : !taskRow?.provider || taskRow.provider === provider
1558
+ ? taskRow.provider
1559
+ : !taskRow.provider || taskRow.provider === provider
1191
1560
  ? provider
1192
1561
  : 'mixed';
1193
1562
  const turnIndex = this.sessionToTurn.get(sessionId)?.index ?? task.lastTurnIndex ?? 0;
@@ -1200,10 +1569,7 @@ export class ApmeCollector {
1200
1569
  this.store.updateTask(task.id, {
1201
1570
  modelId: taskModel,
1202
1571
  provider: taskProvider,
1203
- modelConfig: JSON.stringify({
1204
- modelId: taskModel ?? 'unknown',
1205
- provider: taskProvider ?? null,
1206
- }),
1572
+ modelConfig: this.mergedTaskModelConfig(taskRow, taskModel ?? 'unknown', taskProvider ?? null),
1207
1573
  });
1208
1574
  this.store.recomputeSampleCost(task.id);
1209
1575
  }
@@ -1250,16 +1616,14 @@ export class ApmeCollector {
1250
1616
  updateModel(sessionId, modelId) {
1251
1617
  if (!this.store.enabled || !modelId)
1252
1618
  return;
1253
- const runId = this.sessionToRun.get(sessionId);
1254
- if (!runId)
1619
+ if (!this.sessionToRun.has(sessionId))
1255
1620
  return;
1256
- try {
1257
- this.store.updateRun(runId, {
1258
- modelId,
1259
- provider: normalizeModelProvider(undefined, modelId),
1260
- });
1261
- }
1262
- 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);
1263
1627
  }
1264
1628
  /** Persist the model/provider on the assistant message's own turn.
1265
1629
  * `session_meta` can arrive after `chat.final` closed that turn, so the same
@@ -1317,7 +1681,7 @@ export class ApmeCollector {
1317
1681
  this.store.updateTask(taskId, {
1318
1682
  modelId: taskModel,
1319
1683
  provider: taskProvider,
1320
- modelConfig: JSON.stringify({ modelId: taskModel, provider: taskProvider }),
1684
+ modelConfig: this.mergedTaskModelConfig(task, taskModel, taskProvider),
1321
1685
  });
1322
1686
  }
1323
1687
  catch { /* ignore */ }