@agentdeck/bridge 1.0.18 → 1.0.20

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 (60) hide show
  1. package/dist/adapters/claude-code.d.ts +4 -2
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +9 -10
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adapters/codex-cli.d.ts +3 -4
  6. package/dist/adapters/codex-cli.d.ts.map +1 -1
  7. package/dist/adapters/codex-cli.js +8 -12
  8. package/dist/adapters/codex-cli.js.map +1 -1
  9. package/dist/adapters/pty-adapter.d.ts +2 -2
  10. package/dist/adapters/pty-adapter.js +2 -2
  11. package/dist/apme/adapters/codex-turn-manager.d.ts +13 -77
  12. package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -1
  13. package/dist/apme/adapters/codex-turn-manager.js +102 -403
  14. package/dist/apme/adapters/codex-turn-manager.js.map +1 -1
  15. package/dist/apme/claude-transcript-reader.d.ts +22 -0
  16. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  17. package/dist/apme/claude-transcript-reader.js +75 -1
  18. package/dist/apme/claude-transcript-reader.js.map +1 -1
  19. package/dist/bridge-core.d.ts.map +1 -1
  20. package/dist/bridge-core.js +6 -1
  21. package/dist/bridge-core.js.map +1 -1
  22. package/dist/check-deps.d.ts.map +1 -1
  23. package/dist/check-deps.js +3 -1
  24. package/dist/check-deps.js.map +1 -1
  25. package/dist/claude-turn-watchdog.d.ts +70 -0
  26. package/dist/claude-turn-watchdog.d.ts.map +1 -0
  27. package/dist/claude-turn-watchdog.js +147 -0
  28. package/dist/claude-turn-watchdog.js.map +1 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +20 -3
  31. package/dist/cli.js.map +1 -1
  32. package/dist/codex-hook-silence.d.ts +52 -0
  33. package/dist/codex-hook-silence.d.ts.map +1 -0
  34. package/dist/codex-hook-silence.js +80 -0
  35. package/dist/codex-hook-silence.js.map +1 -0
  36. package/dist/codex-rate-limits.d.ts +1 -1
  37. package/dist/codex-rate-limits.d.ts.map +1 -1
  38. package/dist/codex-rate-limits.js +27 -17
  39. package/dist/codex-rate-limits.js.map +1 -1
  40. package/dist/daemon.js +1 -1
  41. package/dist/index.d.ts +3 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +86 -192
  44. package/dist/index.js.map +1 -1
  45. package/dist/state-machine.d.ts +43 -1
  46. package/dist/state-machine.d.ts.map +1 -1
  47. package/dist/state-machine.js +146 -12
  48. package/dist/state-machine.js.map +1 -1
  49. package/dist/types.d.ts +11 -1
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/version-check.d.ts +8 -0
  53. package/dist/version-check.d.ts.map +1 -1
  54. package/dist/version-check.js +27 -0
  55. package/dist/version-check.js.map +1 -1
  56. package/package.json +4 -3
  57. package/dist/apme/adapters/claude-pty.d.ts +0 -28
  58. package/dist/apme/adapters/claude-pty.d.ts.map +0 -1
  59. package/dist/apme/adapters/claude-pty.js +0 -88
  60. package/dist/apme/adapters/claude-pty.js.map +0 -1
@@ -1,300 +1,76 @@
1
1
  /**
2
- * CodexTurnManager — turn-boundary reconstruction for Codex CLI sessions.
2
+ * Hook-owned Codex turn timeline.
3
3
  *
4
- * Two signal sources are wired into one state machine:
5
- *
6
- * 1. **Hook mode** (primary) `~/.codex/config.toml` lifecycle hooks send
7
- * `codex_user_prompt_submit` / `codex_tool_start` / `codex_tool_end` /
8
- * `codex_stop` to the daemon. These are unambiguous turn boundaries.
9
- * APME span ingestion for hooks happens in `bridge/src/index.ts`'s
10
- * hook branch via `codexHookToSpans` — this manager only adds the
11
- * timeline-visible entries (chat_start / tool_request / chat_response /
12
- * chat_end) so the dashboard sees them.
13
- *
14
- * 2. **PTY-parser fallback** (legacy / hook-miss path) — when no codex_*
15
- * hook has fired in the last 30 s, the manager falls back to the
16
- * pre-hook PTY-only logic: spinner_start opens a turn, prompt-source
17
- * idle (with deferred close + status-line false-positive guard +
18
- * tool-silence latch + PTY tail snapshot) closes it.
19
- *
20
- * The 30 s "hook-fresh" window demotes parser signals to no-ops while
21
- * hooks are flowing, which eliminates the seven-state-variable race we
22
- * landed seven Codex review iterations on. If hooks stop firing (Codex
23
- * Ink-TUI repaint glitch), parser fallback re-engages automatically.
24
- *
25
- * One wedge that window can't fix on its own: a missed `codex_stop`
26
- * leaves the turn open, the parser is muted for 30 s, and the next
27
- * `codex_user_prompt_submit` used to be absorbed into the stale row —
28
- * spinner forever, new prompt invisible. A prompt-submit is therefore
29
- * treated as an authoritative boundary: finding an open turn that is
30
- * neither a hook echo nor a late text fill-in, it closes the stale turn
31
- * before opening the new row (see CODEX_PROMPT_* windows below).
32
- *
33
- * Extracted from `bridge/src/index.ts wireAgentApme` so the codex segment
34
- * can be unit-tested with fake collector / fake ringbuffer / fake adapter.
4
+ * Lifecycle hooks and the notify turn-complete fallback own boundaries. The
5
+ * response body comes from Codex's rollout JSONL (or an inline hook field),
6
+ * never from terminal output. This keeps managed and directly-run Codex on the
7
+ * same structured observation path.
35
8
  */
36
- import { cleanRawText, prepareMarkdownDetail } from '@agentdeck/shared';
37
- import { extractTopicHintWithKind, promptSnippetFallback } from '@agentdeck/shared';
9
+ import { cleanRawText, prepareMarkdownDetail, extractTopicHintWithKind, promptSnippetFallback, } from '@agentdeck/shared';
10
+ import { codexTurnOutcomeFromRollout } from '../../codex-rollout-response.js';
38
11
  import { timelineEntryToSpans } from './timeline.js';
39
12
  import { classifyAndEnqueueTurn } from '../classify-turn.js';
40
- // PTY parser deferred-close window. The parser fires `idle{source: prompt}`
41
- // on any `›\s` match — including the status line shown mid-processing — so
42
- // we wait this long before committing chat_end. A new spinner_start within
43
- // the window cancels the close, which is how we differentiate spurious
44
- // mid-turn idles from real turn ends.
45
- const CODEX_IDLE_CLOSE_DELAY_MS = 1500;
46
- // Upper bound on the tool-silence flag's lifetime when the parser sees a
47
- // timeout-source idle after a tool_action. If Codex finishes the final
48
- // tool and prints the response WITHOUT re-engaging the spinner, no
49
- // spinner_start arrives to clear the flag and any prompt-idle is
50
- // suppressed. After this window we clear the flag defensively and replay
51
- // the suppressed idle as an immediate close.
52
- const CODEX_TOOL_SILENCE_MAX_MS = 15_000;
53
- // How recent a hook must be for the manager to consider hook signals
54
- // authoritative and demote PTY parser signals to no-ops. Any longer and
55
- // we re-arm the parser fallback in case hooks have stopped firing.
56
- const CODEX_HOOK_FRESHNESS_MS = 30_000;
57
- // A prompt-submit hook that finds a turn already open distinguishes three
58
- // cases by age and text. FILL: a text-less turn younger than this was
59
- // opened moments ago by codex_tool_start / parser spinner_start with the
60
- // prompt text arriving late — upsert the text, same turn. Anything older
61
- // is STALE: its codex_stop never arrived (hook drop, daemon restart), and
62
- // absorbing the new prompt into it would leave the row spinning forever
63
- // while the new prompt never gets a row — close it and open fresh.
64
13
  const CODEX_PROMPT_FILL_WINDOW_MS = 5_000;
65
- // DUP: the same prompt text delivered again while the turn it opened is
66
- // still young and tool-less is a hook echo, not a new turn. Mirrors the
67
- // collector's DUPLICATE_TURN_OPEN_WINDOW_MS guard so the timeline and the
68
- // APME store agree on what counts as an echo.
69
14
  const CODEX_PROMPT_DUP_WINDOW_MS = 15_000;
70
15
  export class CodexTurnManager {
71
16
  core;
72
17
  apme;
73
- ptyRingBuffer;
74
18
  sessionId;
75
19
  agentType;
76
- // ── PTY-mode turn-boundary state (legacy) ──
20
+ readOutcome;
77
21
  chatStart = null;
78
22
  lastPromptText = null;
79
- idleCloseTimer = null;
80
- inToolSilence = false;
81
- toolActiveSinceLastSpinner = false;
82
- toolSilenceTimer = null;
83
- pendingPromptIdle = false;
84
- pendingTailSnapshot = null;
85
- // ── Hook-mode tracking ──
86
- hookActive = false;
87
- lastHookTs = 0;
88
- constructor(core, apme, ptyRingBuffer, sessionId, agentType) {
23
+ codexSessionId = null;
24
+ constructor(core, apme, sessionId, agentType, readOutcome = codexTurnOutcomeFromRollout) {
89
25
  this.core = core;
90
26
  this.apme = apme;
91
- this.ptyRingBuffer = ptyRingBuffer;
92
27
  this.sessionId = sessionId;
93
28
  this.agentType = agentType;
29
+ this.readOutcome = readOutcome;
94
30
  }
95
- // ── Public entry points ────────────────────────────────────────────
96
- /** Process a codex_* hook event. Hooks are authoritative — they own
97
- * the turn boundaries while fresh. APME span ingestion already happens
98
- * in `bridge/src/index.ts` via `codexHookToSpans`; this method only
99
- * adds the timeline-visible entries the dashboard renders. */
100
31
  onHookEvent(evt) {
101
- this.hookActive = true;
102
- this.lastHookTs = Date.now();
32
+ this.codexSessionId = extractCodexSessionId(evt.data) ?? this.codexSessionId;
103
33
  if (evt.event === 'codex_user_prompt_submit') {
104
34
  const prompt = extractPrompt(evt.data ?? {});
105
35
  if (this.chatStart !== null) {
106
36
  const age = Date.now() - this.chatStart;
107
- const isDupEcho = prompt.length > 0
108
- && this.lastPromptText === prompt
109
- && !this.toolActiveSinceLastSpinner
110
- && age < CODEX_PROMPT_DUP_WINDOW_MS;
37
+ const isDupEcho = prompt.length > 0 && this.lastPromptText === prompt && age < CODEX_PROMPT_DUP_WINDOW_MS;
111
38
  if (isDupEcho)
112
39
  return;
113
- const isLateTextFill = this.lastPromptText === null
114
- && age < CODEX_PROMPT_FILL_WINDOW_MS;
115
- if (!isLateTextFill) {
116
- // Stale open turn: codex_stop never arrived. A user prompt is an
117
- // authoritative boundary, so close the old turn now — this runs
118
- // before the index.ts hook branch ingests the new turn_start, so
119
- // the APME turn being finalized here is still the stale one.
120
- this.closeOpenTurnNow();
121
- }
40
+ const isLateTextFill = this.lastPromptText === null && age < CODEX_PROMPT_FILL_WINDOW_MS;
41
+ if (!isLateTextFill)
42
+ this.closeOpenTurn();
122
43
  }
123
44
  this.openTimelineChatStart(prompt || undefined);
124
45
  return;
125
46
  }
126
47
  if (evt.event === 'codex_tool_start') {
127
- const data = (evt.data ?? {});
128
- const tool = typeof data.tool_name === 'string' && data.tool_name ? data.tool_name : 'tool';
129
- const args = formatToolArgs(data.tool_input);
48
+ const tool = nonEmptyString(evt.data.tool_name) ?? 'tool';
49
+ const args = formatToolArgs(evt.data.tool_input);
130
50
  const raw = args ? `${tool} ${args}` : tool;
131
51
  this.openTimelineChatStart();
132
- this.toolActiveSinceLastSpinner = true;
133
52
  this.addTimelineEntry({
134
53
  ts: Date.now(),
135
54
  type: 'tool_request',
136
- raw: raw.length > 500 ? raw.slice(0, 497) + '...' : raw,
137
- detail: args && args.length > 0 ? args.slice(0, 1000) : undefined,
55
+ raw: raw.length > 500 ? `${raw.slice(0, 497)}...` : raw,
56
+ detail: args ? args.slice(0, 1000) : undefined,
138
57
  agentType: 'codex-cli',
139
58
  ...(this.chatStart !== null ? { startedAt: this.chatStart } : {}),
140
59
  });
141
60
  return;
142
61
  }
143
- if (evt.event === 'codex_tool_end') {
144
- // Tool finished — stale `›` chunks no longer mean mid-tool.
145
- this.inToolSilence = false;
146
- return;
147
- }
148
- if (evt.event === 'codex_stop') {
149
- // Hook is authoritative — close synchronously.
150
- this.closeOpenTurnNow();
151
- return;
152
- }
153
- }
154
- /** Close whatever turn is open, right now, clearing all PTY-side timers
155
- * and pending state so a late parser idle can't double-fire. Used by
156
- * codex_stop (normal close) and by a prompt-submit that finds a stale
157
- * open turn (missed codex_stop). */
158
- closeOpenTurnNow() {
159
- if (this.idleCloseTimer) {
160
- clearTimeout(this.idleCloseTimer);
161
- this.idleCloseTimer = null;
162
- }
163
- this.exitToolSilence();
164
- this.pendingPromptIdle = false;
165
- this.pendingTailSnapshot = null;
166
- this.toolActiveSinceLastSpinner = false;
167
- if (this.chatStart !== null) {
168
- this.closeTurn(this.chatStart);
62
+ if (evt.event === 'codex_stop' || evt.event === 'codex_turn_complete') {
63
+ this.closeOpenTurn(outcomeFromHook(evt.data));
169
64
  }
170
65
  }
171
- /** Process a PTY parser event. While a hook is fresh, this is a no-op
172
- * for turn-boundary signals (hook owns them). Outside the freshness
173
- * window we run the legacy PTY-only state machine. */
174
- onParserEvent(evt) {
175
- if (this.hookFresh())
176
- return;
177
- if (evt.event === 'spinner_start') {
178
- // Two cases:
179
- // 1. A prompt-idle was suppressed by tool-silence and now spinner
180
- // is starting fresh — that's the user submitting the NEXT
181
- // prompt, so the previous turn really did end. Close it
182
- // synchronously before opening the new one (using the tail
183
- // snapshot taken at latch time so the response doesn't pick
184
- // up turn N+1's input).
185
- // 2. No pending suppression — Codex is just continuing the same
186
- // turn (e.g. resuming after a bash result). Cancel any pending
187
- // deferred close (status-line false positive) and keep the
188
- // existing chat_start.
189
- if (this.pendingPromptIdle && this.chatStart !== null) {
190
- const prevStart = this.chatStart;
191
- const snapshot = this.pendingTailSnapshot;
192
- this.closeTurn(prevStart, snapshot ?? undefined);
193
- }
194
- this.pendingPromptIdle = false;
195
- this.pendingTailSnapshot = null;
196
- if (this.idleCloseTimer) {
197
- clearTimeout(this.idleCloseTimer);
198
- this.idleCloseTimer = null;
199
- }
200
- this.exitToolSilence();
201
- this.toolActiveSinceLastSpinner = false;
202
- this.ensureChatStart();
203
- return;
204
- }
205
- if (evt.event === 'tool_action') {
206
- const data = (evt.data ?? {});
207
- const tool = typeof data.tool === 'string' && data.tool ? data.tool : 'tool';
208
- const args = typeof data.args === 'string' ? data.args : '';
209
- const raw = args ? `${tool} ${args}` : tool;
210
- this.ensureChatStart();
211
- this.toolActiveSinceLastSpinner = true;
212
- // Parser path: APME ingestion has to come from us (no hook adapter
213
- // upstream). addEntryAndIngest fires both the timeline entry and
214
- // the `tool_call` span via timelineEntryToSpans → ingestSpan →
215
- // ingestHook PreToolUse, incrementing turns.tool_calls.
216
- this.addEntryAndIngest({
217
- ts: Date.now(),
218
- type: 'tool_request',
219
- raw: raw.length > 500 ? raw.slice(0, 497) + '...' : raw,
220
- detail: args && args !== raw ? args.slice(0, 1000) : undefined,
221
- agentType: 'codex-cli',
222
- ...(this.chatStart !== null ? { startedAt: this.chatStart } : {}),
223
- });
224
- return;
225
- }
226
- if (evt.event === 'idle') {
227
- const idleSource = evt.data?.source;
228
- if (idleSource === 'timeout') {
229
- // Synthetic idle from spinner-data silence. Only treat as mid-tool
230
- // if a tool actually ran in this thinking segment — end-of-turn
231
- // quiet (final response with no further spinning) also produces
232
- // a timeout idle and must NOT block the next prompt-idle close.
233
- if (this.toolActiveSinceLastSpinner) {
234
- this.inToolSilence = true;
235
- // Bound the silence: if no spinner_start arrives within the
236
- // grace window, Codex finished the tool and ended the turn
237
- // without re-engaging the spinner (e.g. tool output IS the
238
- // final response). On timer fire we clear the flag and replay
239
- // any suppressed prompt-idle as an immediate close.
240
- if (this.toolSilenceTimer)
241
- clearTimeout(this.toolSilenceTimer);
242
- this.toolSilenceTimer = setTimeout(() => {
243
- this.toolSilenceTimer = null;
244
- this.inToolSilence = false;
245
- if (this.pendingPromptIdle) {
246
- const snapshot = this.pendingTailSnapshot;
247
- this.pendingPromptIdle = false;
248
- this.pendingTailSnapshot = null;
249
- if (this.chatStart !== null) {
250
- // Close immediately rather than via scheduleClose: we
251
- // already waited the full silence window, and any
252
- // further deferral would let a quickly-typed next
253
- // prompt cancel the close entirely.
254
- this.closeTurn(this.chatStart, snapshot ?? undefined);
255
- }
256
- }
257
- }, CODEX_TOOL_SILENCE_MAX_MS);
258
- }
259
- return;
260
- }
261
- if (idleSource !== 'prompt')
262
- return;
263
- if (this.chatStart === null)
264
- return;
265
- if (this.inToolSilence) {
266
- // Latch this prompt-idle, with a PTY tail snapshot pinned to
267
- // *this* moment. Either the auto-clear timer will replay it
268
- // (turn really ended without re-spinning) or the next
269
- // spinner_start will close turn N before opening N+1. Both
270
- // paths use the snapshot — the live tail by then would be
271
- // contaminated with N+1's input/processing.
272
- this.pendingPromptIdle = true;
273
- this.pendingTailSnapshot = this.ptyRingBuffer.getTail(5000);
274
- return;
275
- }
276
- // Defer the close. A new spinner_start within the delay cancels
277
- // this timer, which is how we reject status-line false positives
278
- // (the status row also matches IDLE_PROMPT) without a wall-clock
279
- // guard that would also drop real fast turns.
280
- this.scheduleClose(this.chatStart);
281
- return;
282
- }
283
- }
284
- /** Clear timers so a session shutdown leaves no lingering work. */
285
66
  cleanup() {
286
- if (this.idleCloseTimer) {
287
- clearTimeout(this.idleCloseTimer);
288
- this.idleCloseTimer = null;
289
- }
290
- if (this.toolSilenceTimer) {
291
- clearTimeout(this.toolSilenceTimer);
292
- this.toolSilenceTimer = null;
293
- }
67
+ // Kept for the adapter lifecycle contract; hook-owned turns have no timers.
294
68
  }
295
- // ── Internal helpers ───────────────────────────────────────────────
296
- hookFresh() {
297
- return this.hookActive && (Date.now() - this.lastHookTs) < CODEX_HOOK_FRESHNESS_MS;
69
+ closeOpenTurn(inline) {
70
+ if (this.chatStart === null)
71
+ return;
72
+ const rollout = this.codexSessionId ? this.readOutcome(this.codexSessionId) : { text: '' };
73
+ this.closeTurn(this.chatStart, preferOutcome(inline, rollout));
298
74
  }
299
75
  makeCtx() {
300
76
  return {
@@ -308,9 +84,6 @@ export class CodexTurnManager {
308
84
  addTimelineEntry(entry) {
309
85
  this.core.bridgeTimeline.addEntry(entry);
310
86
  }
311
- /** Add a timeline entry AND feed it through APME ingestion. Use this
312
- * on the PTY-fallback path where no upstream hook adapter has done
313
- * the ingestion. */
314
87
  addEntryAndIngest(entry) {
315
88
  this.core.bridgeTimeline.addEntry(entry);
316
89
  const ctx = this.makeCtx();
@@ -318,24 +91,15 @@ export class CodexTurnManager {
318
91
  this.apme.collector.ingestSpan(this.sessionId, span);
319
92
  }
320
93
  }
321
- /** Hook-path chat_start opener: timeline entry only. The APME turn
322
- * was opened upstream by codexHookToSpans → ingestSpan(turn_start)
323
- * in the index.ts hook branch. Re-ingesting here would close the
324
- * just-opened turn and immediately reopen another. */
325
94
  openTimelineChatStart(text) {
326
95
  if (this.chatStart !== null) {
327
- // Upsert prompt text into the existing entry if we now have it.
328
96
  if (text && !this.lastPromptText) {
329
97
  this.lastPromptText = text;
330
- const raw = text.length > 500 ? text.slice(0, 497) + '...' : text;
331
- const detail = text.length > 100
332
- ? (text.length > 1000 ? text.slice(0, 997) + '...' : text)
333
- : undefined;
334
98
  this.core.bridgeTimeline.upsertEntry({
335
99
  ts: this.chatStart,
336
100
  type: 'chat_start',
337
- raw,
338
- ...(detail ? { detail } : {}),
101
+ raw: text.length > 500 ? `${text.slice(0, 497)}...` : text,
102
+ ...(text.length > 100 ? { detail: text.slice(0, 1000) } : {}),
339
103
  agentType: 'codex-cli',
340
104
  startedAt: this.chatStart,
341
105
  });
@@ -345,186 +109,121 @@ export class CodexTurnManager {
345
109
  const now = Date.now();
346
110
  this.chatStart = now;
347
111
  this.lastPromptText = text || null;
348
- const raw = text
349
- ? (text.length > 500 ? text.slice(0, 497) + '...' : text)
350
- : 'Codex turn started';
351
- const detail = text && text.length > 100
352
- ? (text.length > 1000 ? text.slice(0, 997) + '...' : text)
353
- : undefined;
354
112
  this.addTimelineEntry({
355
113
  ts: now,
356
114
  type: 'chat_start',
357
- raw,
358
- ...(detail ? { detail } : {}),
359
- agentType: 'codex-cli',
360
- startedAt: now,
361
- });
362
- return now;
363
- }
364
- /** PTY-path chat_start opener: timeline entry AND APME ingestion. */
365
- ensureChatStart(text) {
366
- if (this.chatStart !== null) {
367
- if (text && !this.lastPromptText) {
368
- this.lastPromptText = text;
369
- const raw = text.length > 500 ? text.slice(0, 497) + '...' : text;
370
- const detail = text.length > 100
371
- ? (text.length > 1000 ? text.slice(0, 997) + '...' : text)
372
- : undefined;
373
- this.core.bridgeTimeline.upsertEntry({
374
- ts: this.chatStart,
375
- type: 'chat_start',
376
- raw,
377
- ...(detail ? { detail } : {}),
378
- agentType: 'codex-cli',
379
- startedAt: this.chatStart,
380
- });
381
- }
382
- return this.chatStart;
383
- }
384
- const now = Date.now();
385
- this.chatStart = now;
386
- this.lastPromptText = text || null;
387
- const raw = text
388
- ? (text.length > 500 ? text.slice(0, 497) + '...' : text)
389
- : 'Codex turn started';
390
- const detail = text && text.length > 100
391
- ? (text.length > 1000 ? text.slice(0, 997) + '...' : text)
392
- : undefined;
393
- this.addEntryAndIngest({
394
- ts: now,
395
- type: 'chat_start',
396
- raw,
397
- ...(detail ? { detail } : {}),
115
+ raw: text ? (text.length > 500 ? `${text.slice(0, 497)}...` : text) : 'Codex turn started',
116
+ ...(text && text.length > 100 ? { detail: text.slice(0, 1000) } : {}),
398
117
  agentType: 'codex-cli',
399
118
  startedAt: now,
400
119
  });
401
120
  return now;
402
121
  }
403
- /** Close the turn that started at `startedAt`: extract the response
404
- * tail (live ringbuffer or passed snapshot), emit chat_response +
405
- * chat_end timeline entries, and ingest the response span. The
406
- * snapshot path is used when closing in response to a delayed signal
407
- * (auto-clear timer or next spinner_start) — by that point the live
408
- * ringbuffer contains turn N+1 content that would contaminate N's
409
- * response. */
410
- closeTurn(startedAt, tailSnapshot) {
122
+ closeTurn(startedAt, outcome) {
411
123
  if (this.chatStart !== startedAt)
412
124
  return;
413
125
  const endedAt = Date.now();
414
126
  const promptText = this.lastPromptText;
415
127
  this.chatStart = null;
416
128
  this.lastPromptText = null;
417
- const tail = tailSnapshot ?? this.ptyRingBuffer.getTail(5000);
418
- const lines = tail.split('\n').map(l => l.trim()).filter(Boolean);
419
- const clean = lines.filter(l => !/^[✢✳✶✻✽⏸⏵❯─>]/.test(l) &&
420
- !/planmode|plan\s*mode|shift\+tab|accept\s*edits/i.test(l) &&
421
- !/\?\s*for\s*shortcuts/.test(l));
422
- const response = clean.slice(-5).join('\n');
423
- if (response.length > 2) {
424
- const respRaw = response.length > 200 ? response.slice(0, 197) + '...' : response;
425
- // chat_response routes through addEntryAndIngest so a turn_response
426
- // span calls setTurnResponse on APME's active turn (works in both
427
- // hook and PTY modes — the chat_response timeline → ingestSpan
428
- // path attaches to whichever turn the collector currently has
429
- // open).
129
+ const response = outcome.text.trim();
130
+ const error = outcome.error?.trim() ?? '';
131
+ const body = response || error;
132
+ if (body) {
133
+ const raw = response
134
+ ? body.length > 200
135
+ ? `${body.slice(0, 197)}...`
136
+ : body
137
+ : `Error: ${body.length > 190 ? `${body.slice(0, 187)}...` : body}`;
430
138
  this.addEntryAndIngest({
431
139
  ts: endedAt - 1,
432
140
  type: 'chat_response',
433
- raw: cleanRawText(respRaw),
434
- detail: prepareMarkdownDetail(response.slice(0, 3000)) || undefined,
141
+ raw: cleanRawText(raw),
142
+ detail: prepareMarkdownDetail(body.slice(0, 3000)) || undefined,
435
143
  agentType: 'codex-cli',
436
144
  startedAt,
437
145
  endedAt,
438
146
  });
439
147
  void classifyAndEnqueueTurn(this.apme, this.sessionId);
440
148
  }
441
- // Finalize the APME turn row: flushes endedAt + buffered tool_calls /
442
- // file counters to the store. Order matters — setTurnResponse and
443
- // classifyAndEnqueueTurn above need the turn to still be ACTIVE
444
- // (they read from sessionToTurn). After this call the turn moves
445
- // into sessionToLastTurnId; setLastClosedTurnResponse from any
446
- // late chat_end fallback would target this turn.
447
149
  this.apme.collector.closeTurnForSession(this.sessionId);
448
150
  const duration = Math.round((endedAt - startedAt) / 1000);
449
- // Same kind-classification as wireClaudeCodeTimeline / openclaw adapter.
450
- // 'topic' → real heuristic; fallback / generic label → 'none' so client
451
- // suppresses the (likely-redundant) detail pane.
452
- const respHint = response ? extractTopicHintWithKind(response) : { hint: null, kind: null };
453
- const promptHint = promptText ? extractTopicHintWithKind(promptText) : { hint: null, kind: null };
454
- let label;
455
- let summaryKind;
456
- if (respHint.kind === 'topic' && respHint.hint) {
457
- label = respHint.hint;
458
- summaryKind = 'heuristic';
459
- }
460
- else if (promptHint.kind === 'topic' && promptHint.hint) {
461
- label = promptHint.hint;
462
- summaryKind = 'heuristic';
463
- }
464
- else if (respHint.hint || promptHint.hint) {
465
- label = (respHint.hint || promptHint.hint);
466
- summaryKind = 'none';
467
- }
468
- else {
469
- label = promptSnippetFallback(promptText, 60) ?? 'Codex turn completed';
470
- summaryKind = 'none';
471
- }
472
- // chat_end is timeline-only (display marker for duration + topic).
473
- // The `turn_response` mapping with `fallback_to_last_closed` would
474
- // otherwise attach this response to the previously closed turn —
475
- // wrong target. Response already on current turn from chat_response.
151
+ const responseHint = body
152
+ ? extractTopicHintWithKind(body)
153
+ : { hint: null, kind: null };
154
+ const promptHint = promptText
155
+ ? extractTopicHintWithKind(promptText)
156
+ : { hint: null, kind: null };
157
+ const label = responseHint.hint ?? promptHint.hint ?? promptSnippetFallback(promptText, 60) ?? 'Codex turn completed';
158
+ const summaryKind = responseHint.kind === 'topic' || promptHint.kind === 'topic' ? 'heuristic' : 'none';
476
159
  this.addTimelineEntry({
477
160
  ts: endedAt,
478
161
  type: 'chat_end',
479
162
  raw: `${label} · ${duration}s`,
480
- detail: response.length > 2 ? prepareMarkdownDetail(response.slice(0, 1000)) || undefined : undefined,
163
+ ...(body ? { detail: prepareMarkdownDetail(body.slice(0, 1000)) || undefined } : {}),
481
164
  agentType: 'codex-cli',
482
165
  startedAt,
483
166
  endedAt,
484
167
  summaryKind,
485
168
  });
486
169
  }
487
- scheduleClose(startedAt, tailSnapshot) {
488
- if (this.idleCloseTimer)
489
- clearTimeout(this.idleCloseTimer);
490
- this.idleCloseTimer = setTimeout(() => {
491
- this.idleCloseTimer = null;
492
- this.closeTurn(startedAt, tailSnapshot);
493
- }, CODEX_IDLE_CLOSE_DELAY_MS);
170
+ }
171
+ function nonEmptyString(value) {
172
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
173
+ }
174
+ function extractPrompt(data) {
175
+ const message = data.message;
176
+ if (message && typeof message === 'object') {
177
+ const content = message.content;
178
+ if (typeof content === 'string')
179
+ return content;
494
180
  }
495
- exitToolSilence() {
496
- this.inToolSilence = false;
497
- if (this.toolSilenceTimer) {
498
- clearTimeout(this.toolSilenceTimer);
499
- this.toolSilenceTimer = null;
500
- }
181
+ return nonEmptyString(data.prompt) ?? nonEmptyString(data.user_prompt) ?? '';
182
+ }
183
+ function extractCodexSessionId(data) {
184
+ for (const key of [
185
+ 'thread_id',
186
+ 'threadId',
187
+ 'thread-id',
188
+ 'codex.thread_id',
189
+ 'session_id',
190
+ 'sessionId',
191
+ 'session-id',
192
+ ]) {
193
+ const value = nonEmptyString(data[key]);
194
+ const normalized = value?.replace(/^codex:/, '');
195
+ if (normalized && /^[0-9a-f-]{8,}$/i.test(normalized))
196
+ return normalized;
501
197
  }
198
+ return null;
502
199
  }
503
- // ── Local helpers ────────────────────────────────────────────────────
504
- function extractPrompt(data) {
505
- const fromMessage = (() => {
506
- const msg = data.message;
507
- if (msg && typeof msg === 'object') {
508
- const content = msg.content;
509
- return typeof content === 'string' ? content : '';
510
- }
511
- return '';
512
- })();
513
- if (fromMessage)
514
- return fromMessage;
515
- if (typeof data.prompt === 'string')
516
- return data.prompt;
517
- if (typeof data.user_prompt === 'string')
518
- return data.user_prompt;
519
- return '';
200
+ function outcomeFromHook(data) {
201
+ for (const key of ['last_assistant_message', 'last-assistant-message', 'response', 'output', 'result']) {
202
+ const text = nonEmptyString(data[key]);
203
+ if (text)
204
+ return { text };
205
+ }
206
+ // Only an explicit `error` key maps to an error. `message` must NOT: on
207
+ // every other Codex event it carries content, and across 311 recorded real
208
+ // codex_stop payloads (≤0.146.0) neither key ever appeared — so a future
209
+ // benign `message` rendering every turn as "Error: …" is the costly
210
+ // misread, while a hypothetical message-shaped error only loses a label.
211
+ const error = nonEmptyString(data.error);
212
+ return { text: '', ...(error ? { error } : {}) };
213
+ }
214
+ function preferOutcome(inline, rollout) {
215
+ if (inline?.text)
216
+ return inline;
217
+ if (rollout.text)
218
+ return rollout;
219
+ if (inline?.error)
220
+ return inline;
221
+ return rollout;
520
222
  }
521
223
  function formatToolArgs(input) {
522
224
  if (typeof input === 'string')
523
225
  return input;
524
226
  if (input && typeof input === 'object') {
525
- // Codex hook payloads typically pack the bash command in `command` or
526
- // `cmd`. Best-effort extraction; fall through to JSON for everything
527
- // else so the tool args are still visible on the timeline.
528
227
  const obj = input;
529
228
  if (typeof obj.command === 'string')
530
229
  return obj.command;