@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.
- 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 +109 -3
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +408 -44
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +4 -2
- 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 +4 -0
- 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 +13 -1
- 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 +77 -8
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +189 -17
- 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/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.map +1 -1
- package/dist/cli.js +28 -8
- package/dist/cli.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 +329 -34
- package/dist/daemon-server.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 +11 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +57 -2
- package/dist/esp32-serial.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/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.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/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/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/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/package.json +5 -5
package/dist/apme/collector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
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
|
|
471
|
-
//
|
|
472
|
-
|
|
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
|
-
},
|
|
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
|
|
737
|
-
if (id
|
|
738
|
-
|
|
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
|
-
|
|
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
|
|
1185
|
-
: !taskRow
|
|
1553
|
+
? taskRow.modelId
|
|
1554
|
+
: !taskRow.modelId || taskRow.modelId === model
|
|
1186
1555
|
? model
|
|
1187
1556
|
: 'mixed';
|
|
1188
1557
|
const taskProvider = !provider || provider === 'unknown'
|
|
1189
|
-
? taskRow
|
|
1190
|
-
: !taskRow
|
|
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:
|
|
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
|
-
|
|
1254
|
-
if (!runId)
|
|
1619
|
+
if (!this.sessionToRun.has(sessionId))
|
|
1255
1620
|
return;
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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:
|
|
1684
|
+
modelConfig: this.mergedTaskModelConfig(task, taskModel, taskProvider),
|
|
1321
1685
|
});
|
|
1322
1686
|
}
|
|
1323
1687
|
catch { /* ignore */ }
|