@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.
- package/dist/adapters/claude-code.d.ts +4 -2
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +9 -10
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/codex-cli.d.ts +3 -4
- package/dist/adapters/codex-cli.d.ts.map +1 -1
- package/dist/adapters/codex-cli.js +8 -12
- package/dist/adapters/codex-cli.js.map +1 -1
- package/dist/adapters/pty-adapter.d.ts +2 -2
- package/dist/adapters/pty-adapter.js +2 -2
- package/dist/apme/adapters/codex-turn-manager.d.ts +13 -77
- package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -1
- package/dist/apme/adapters/codex-turn-manager.js +102 -403
- package/dist/apme/adapters/codex-turn-manager.js.map +1 -1
- package/dist/apme/claude-transcript-reader.d.ts +22 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +75 -1
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +6 -1
- package/dist/bridge-core.js.map +1 -1
- package/dist/check-deps.d.ts.map +1 -1
- package/dist/check-deps.js +3 -1
- package/dist/check-deps.js.map +1 -1
- package/dist/claude-turn-watchdog.d.ts +70 -0
- package/dist/claude-turn-watchdog.d.ts.map +1 -0
- package/dist/claude-turn-watchdog.js +147 -0
- package/dist/claude-turn-watchdog.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +20 -3
- package/dist/cli.js.map +1 -1
- package/dist/codex-hook-silence.d.ts +52 -0
- package/dist/codex-hook-silence.d.ts.map +1 -0
- package/dist/codex-hook-silence.js +80 -0
- package/dist/codex-hook-silence.js.map +1 -0
- package/dist/codex-rate-limits.d.ts +1 -1
- package/dist/codex-rate-limits.d.ts.map +1 -1
- package/dist/codex-rate-limits.js +27 -17
- package/dist/codex-rate-limits.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +86 -192
- package/dist/index.js.map +1 -1
- package/dist/state-machine.d.ts +43 -1
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +146 -12
- package/dist/state-machine.js.map +1 -1
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/version-check.d.ts +8 -0
- package/dist/version-check.d.ts.map +1 -1
- package/dist/version-check.js +27 -0
- package/dist/version-check.js.map +1 -1
- package/package.json +4 -3
- package/dist/apme/adapters/claude-pty.d.ts +0 -28
- package/dist/apme/adapters/claude-pty.d.ts.map +0 -1
- package/dist/apme/adapters/claude-pty.js +0 -88
- package/dist/apme/adapters/claude-pty.js.map +0 -1
|
@@ -1,300 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Hook-owned Codex turn timeline.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
|
|
20
|
+
readOutcome;
|
|
77
21
|
chatStart = null;
|
|
78
22
|
lastPromptText = null;
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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
|
-
|
|
115
|
-
|
|
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
|
|
128
|
-
const
|
|
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)
|
|
137
|
-
detail: args
|
|
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 === '
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
|
418
|
-
const
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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(
|
|
434
|
-
detail: prepareMarkdownDetail(
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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;
|