@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.
- package/README.md +14 -6
- package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
- package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
- package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
- package/assets/fonts/pocket-sans-world.manifest.json +49 -0
- package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
- package/assets/learning/NOTICE.md +33 -0
- package/assets/learning/jp-n3-ko.manifest.json +27 -0
- package/assets/learning/jp-n3-ko.pdl +0 -0
- package/dist/agent-cli-diagnostics.d.ts +20 -0
- package/dist/agent-cli-diagnostics.d.ts.map +1 -0
- package/dist/agent-cli-diagnostics.js +100 -0
- package/dist/agent-cli-diagnostics.js.map +1 -0
- package/dist/apme/classifier.d.ts.map +1 -1
- package/dist/apme/classifier.js +18 -10
- package/dist/apme/classifier.js.map +1 -1
- package/dist/apme/claude-transcript-reader.d.ts +36 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +78 -2
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/apme/collector.d.ts +126 -3
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +450 -41
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +278 -36
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/graph.d.ts +1 -1
- package/dist/apme/graph.d.ts.map +1 -1
- package/dist/apme/graph.js +51 -1
- package/dist/apme/graph.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +37 -5
- package/dist/apme/http.js.map +1 -1
- package/dist/apme/index.d.ts +4 -0
- package/dist/apme/index.d.ts.map +1 -1
- package/dist/apme/index.js +19 -3
- package/dist/apme/index.js.map +1 -1
- package/dist/apme/runner.d.ts +90 -0
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +524 -48
- package/dist/apme/runner.js.map +1 -1
- package/dist/apme/sample-to-timeline.d.ts.map +1 -1
- package/dist/apme/sample-to-timeline.js +5 -0
- package/dist/apme/sample-to-timeline.js.map +1 -1
- package/dist/apme/settings.d.ts +8 -0
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +4 -0
- package/dist/apme/settings.js.map +1 -1
- package/dist/apme/store.d.ts +118 -9
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +342 -44
- package/dist/apme/store.js.map +1 -1
- package/dist/apme/task-gradeability.d.ts +82 -0
- package/dist/apme/task-gradeability.d.ts.map +1 -0
- package/dist/apme/task-gradeability.js +126 -0
- package/dist/apme/task-gradeability.js.map +1 -0
- package/dist/awaiting-overlay.d.ts +13 -26
- package/dist/awaiting-overlay.d.ts.map +1 -1
- package/dist/awaiting-overlay.js +7 -40
- package/dist/awaiting-overlay.js.map +1 -1
- package/dist/bridge-core.d.ts +15 -0
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +30 -4
- package/dist/bridge-core.js.map +1 -1
- package/dist/claude-permission-rules.d.ts +20 -32
- package/dist/claude-permission-rules.d.ts.map +1 -1
- package/dist/claude-permission-rules.js +19 -102
- package/dist/claude-permission-rules.js.map +1 -1
- package/dist/claude-usage-recovery.d.ts +54 -0
- package/dist/claude-usage-recovery.d.ts.map +1 -0
- package/dist/claude-usage-recovery.js +200 -0
- package/dist/claude-usage-recovery.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +81 -14
- package/dist/cli.js.map +1 -1
- package/dist/codex-rate-limits-live.d.ts +180 -10
- package/dist/codex-rate-limits-live.d.ts.map +1 -1
- package/dist/codex-rate-limits-live.js +475 -22
- package/dist/codex-rate-limits-live.js.map +1 -1
- package/dist/codex-rollout-response.d.ts +43 -0
- package/dist/codex-rollout-response.d.ts.map +1 -1
- package/dist/codex-rollout-response.js +67 -0
- package/dist/codex-rollout-response.js.map +1 -1
- package/dist/coordination-evidence.d.ts +150 -0
- package/dist/coordination-evidence.d.ts.map +1 -0
- package/dist/coordination-evidence.js +455 -0
- package/dist/coordination-evidence.js.map +1 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +592 -35
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon-ws-client.d.ts +1 -0
- package/dist/daemon-ws-client.d.ts.map +1 -1
- package/dist/daemon-ws-client.js +25 -16
- package/dist/daemon-ws-client.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/deferred-reply-read.d.ts +24 -0
- package/dist/deferred-reply-read.d.ts.map +1 -0
- package/dist/deferred-reply-read.js +42 -0
- package/dist/deferred-reply-read.js.map +1 -0
- package/dist/esp32-serial.d.ts +15 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +61 -2
- package/dist/esp32-serial.js.map +1 -1
- package/dist/font-pack.d.ts +15 -0
- package/dist/font-pack.d.ts.map +1 -0
- package/dist/font-pack.js +75 -0
- package/dist/font-pack.js.map +1 -0
- package/dist/glance-frame.d.ts.map +1 -1
- package/dist/glance-frame.js +2 -0
- package/dist/glance-frame.js.map +1 -1
- package/dist/hook-codex-sessions.d.ts +9 -0
- package/dist/hook-codex-sessions.d.ts.map +1 -1
- package/dist/hook-codex-sessions.js +35 -2
- package/dist/hook-codex-sessions.js.map +1 -1
- package/dist/hook-opencode-sessions.d.ts +62 -0
- package/dist/hook-opencode-sessions.d.ts.map +1 -0
- package/dist/hook-opencode-sessions.js +180 -0
- package/dist/hook-opencode-sessions.js.map +1 -0
- package/dist/hook-response-source.d.ts +40 -0
- package/dist/hook-response-source.d.ts.map +1 -0
- package/dist/hook-response-source.js +31 -0
- package/dist/hook-response-source.js.map +1 -0
- package/dist/http-auth-gate.d.ts.map +1 -1
- package/dist/http-auth-gate.js +9 -1
- package/dist/http-auth-gate.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/learning-pack.d.ts +24 -0
- package/dist/learning-pack.d.ts.map +1 -0
- package/dist/learning-pack.js +125 -0
- package/dist/learning-pack.js.map +1 -0
- package/dist/mdns.js +1 -1
- package/dist/mdns.js.map +1 -1
- package/dist/native-dependency-diagnostics.d.ts +29 -0
- package/dist/native-dependency-diagnostics.d.ts.map +1 -0
- package/dist/native-dependency-diagnostics.js +95 -0
- package/dist/native-dependency-diagnostics.js.map +1 -0
- package/dist/observed-steering.d.ts +4 -4
- package/dist/observed-steering.d.ts.map +1 -1
- package/dist/observed-steering.js +28 -32
- package/dist/observed-steering.js.map +1 -1
- package/dist/openclaw-gateway-stability.d.ts +94 -0
- package/dist/openclaw-gateway-stability.d.ts.map +1 -0
- package/dist/openclaw-gateway-stability.js +176 -0
- package/dist/openclaw-gateway-stability.js.map +1 -0
- package/dist/pairing-knocks.d.ts +80 -0
- package/dist/pairing-knocks.d.ts.map +1 -0
- package/dist/pairing-knocks.js +312 -0
- package/dist/pairing-knocks.js.map +1 -0
- package/dist/passive-observer.d.ts +5 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +6 -0
- package/dist/passive-observer.js.map +1 -1
- package/dist/pixoo/micro-glyphs.d.ts +4 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
- package/dist/pixoo/micro-glyphs.js +29 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -1
- package/dist/pixoo/pixoo-font.d.ts.map +1 -1
- package/dist/pixoo/pixoo-font.js +3 -1
- package/dist/pixoo/pixoo-font.js.map +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +17 -2
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pty-manager.d.ts +9 -0
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +63 -4
- package/dist/pty-manager.js.map +1 -1
- package/dist/relayed-usage.d.ts +30 -0
- package/dist/relayed-usage.d.ts.map +1 -1
- package/dist/relayed-usage.js +25 -2
- package/dist/relayed-usage.js.map +1 -1
- package/dist/review-runner.js +1 -1
- package/dist/subagent-timeline.d.ts +27 -0
- package/dist/subagent-timeline.d.ts.map +1 -1
- package/dist/subagent-timeline.js +52 -5
- package/dist/subagent-timeline.js.map +1 -1
- package/dist/surface-protocol.d.ts.map +1 -1
- package/dist/surface-protocol.js +2 -0
- package/dist/surface-protocol.js.map +1 -1
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +8 -1
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/usage-api.d.ts +3 -0
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +101 -36
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +2 -1
- package/dist/usage-event.js.map +1 -1
- package/dist/ws-server.d.ts +18 -0
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +40 -2
- package/dist/ws-server.js.map +1 -1
- package/package.json +6 -5
- package/src/timebox/sync_ble.py +35 -16
package/dist/apme/collector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
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
|
|
692
|
-
if (id
|
|
693
|
-
|
|
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
|
-
|
|
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
|
|
1140
|
-
: !taskRow
|
|
1553
|
+
? taskRow.modelId
|
|
1554
|
+
: !taskRow.modelId || taskRow.modelId === model
|
|
1141
1555
|
? model
|
|
1142
1556
|
: 'mixed';
|
|
1143
1557
|
const taskProvider = !provider || provider === 'unknown'
|
|
1144
|
-
? taskRow
|
|
1145
|
-
: !taskRow
|
|
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:
|
|
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
|
-
|
|
1209
|
-
if (!runId)
|
|
1619
|
+
if (!this.sessionToRun.has(sessionId))
|
|
1210
1620
|
return;
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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:
|
|
1684
|
+
modelConfig: this.mergedTaskModelConfig(task, taskModel, taskProvider),
|
|
1276
1685
|
});
|
|
1277
1686
|
}
|
|
1278
1687
|
catch { /* ignore */ }
|