@gotcos/glasses-server 6.51.0 → 6.52.3

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 (34) hide show
  1. package/.env.example +16 -0
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +56 -3
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +58 -9
  7. package/server/lib/activity-preview.ts +45 -2
  8. package/server/lib/agent-session-search.ts +3 -1
  9. package/server/lib/agent-session-store.ts +35 -5
  10. package/server/lib/agent-session-turns.ts +4 -1
  11. package/server/lib/claude-bridge.ts +9 -0
  12. package/server/lib/client-instance-claim.ts +22 -1
  13. package/server/lib/client-liveness.ts +50 -0
  14. package/server/lib/codex-bridge.ts +6 -0
  15. package/server/lib/cursor-bridge.ts +6 -0
  16. package/server/lib/job-trail.ts +758 -0
  17. package/server/lib/ollama-bridge.ts +17 -0
  18. package/server/lib/permission-broker.ts +1156 -0
  19. package/server/lib/query-job-coordinator.ts +17 -1
  20. package/server/lib/query-job-feature.ts +10 -0
  21. package/server/lib/query-job-runtime.ts +9 -1
  22. package/server/lib/query-job-store.ts +214 -5
  23. package/server/lib/query-job-types.ts +36 -0
  24. package/server/lib/session-signal-store.ts +42 -10
  25. package/server/lib/session-state-derive.ts +14 -4
  26. package/server/lib/session-stream-events.ts +629 -18
  27. package/server/lib/session-stream-producer.ts +25 -6
  28. package/server/lib/session-transcript-watcher.ts +120 -3
  29. package/server/routes/agent-sessions.ts +12 -1
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +14 -7
  32. package/server/routes/health.ts +24 -1
  33. package/server/routes/permission-broker.ts +176 -0
  34. package/server/routes/query-jobs.ts +84 -13
@@ -0,0 +1,758 @@
1
+ // The Messages trail (6.52.0): a readable account of a running Messages job.
2
+ //
3
+ // WHAT IT IS. A Messages run used to show raw tool lines (`Bash: <command>`) and a stream
4
+ // of text in which the agent's narration and its final answer ran together. Sessions
5
+ // already solved "what is it doing": one grammar (session-stream-events.ts) of steps with
6
+ // a verb and a short target, the agent's own words between them, and a status. This module
7
+ // is the second, ISOLATED reader of each Messages bridge's stdout that feeds that grammar,
8
+ // and a few small mappers where an engine writes a shape the grammar does not read.
9
+ //
10
+ // ISOLATION IS THE CONTRACT. The bridges' own stdout handlers decide the final answer,
11
+ // the chunks, the activity lines and every terminal path, and they are untouched. The tee
12
+ // is a SEPARATE `data` listener registered AFTER the bridge's (the session-stream producer
13
+ // pattern), holds no state the bridge can see, and swallows its own failures. When a caller
14
+ // passes no `onTrail`, no listener is registered at all, which is the path the legacy
15
+ // `/api/query` route, openai-compat and a server with `COS_MESSAGES_TRAIL=0` all take.
16
+ //
17
+ // WHAT NEVER ENTERS THE TRAIL:
18
+ // - a prompt. The job's own prompt echo, a replayed steer, an injected user row: none of
19
+ // them is ever a trail entry (`fromGrammar`). The only thing a user row contributes is
20
+ // the OUTCOME of a tool call.
21
+ // - lifecycle status. `init`/`result`, Codex `turn.*`: the job status is authoritative,
22
+ // and a trail that said "done" before the journal did would contradict it.
23
+ // - model reasoning. Thinking blocks, Codex `reasoning` items and Cursor `thinking`
24
+ // records are dropped, as the shipped copy promises.
25
+ //
26
+ // Redaction and the user's tool-activity opt-out are applied at the journal boundary and
27
+ // in the coordinator respectively (`sanitizeJobTrailDraft`, `applyTrailToolMode`), so a
28
+ // draft cannot reach the journal without both.
29
+ //
30
+ // REDACT BEFORE CUT, EVERYWHERE (QA round 1). A secret straddling a cut is a prefix no
31
+ // pattern recognises any more, so the readers redact every visible field in full BEFORE the
32
+ // grammar or a mapper shortens it (`preRedactClaudeRecord`, `trailLine`, `secretFree`), and
33
+ // the journal boundary redacts again. Every redaction reads at most
34
+ // REDACTION_SCAN_MAX_CHARS, cut at a token boundary, so no input can make a regex run long.
35
+
36
+ import { StringDecoder } from 'node:string_decoder'
37
+ import { boundForRedaction, redactSecretText } from './activity-preview.js'
38
+ import { redactQueryJobText } from './query-job-types.js'
39
+ import {
40
+ DETAIL_MAX_CHARS,
41
+ TARGET_MAX_CHARS,
42
+ TRUNCATION_MARK,
43
+ basename,
44
+ codexExecCommandOutcome,
45
+ codexExecCommandText,
46
+ commandSummary,
47
+ draftsFromRecord,
48
+ oneLine,
49
+ targetForTool,
50
+ verbForToolName,
51
+ type SessionStreamDraft,
52
+ type SessionStreamVerb,
53
+ type ToolOutcome,
54
+ } from './session-stream-events.js'
55
+ import { createLineAssembler } from './session-stream-producer.js'
56
+
57
+ export type JobTrailProvider = 'claude' | 'codex' | 'cursor'
58
+
59
+ /**
60
+ * One trail entry, before transport stamps.
61
+ *
62
+ * The SAME kinds and fields as the Sessions grammar, so the app renders both with one
63
+ * reducer. Two differences, both narrowing: there is no `prompt` kind (see the header), and
64
+ * `status` is always `working` because lifecycle belongs to the job. `status` carries either
65
+ * a `tool_outcome` (the result of the step before it) or a `detail`: Claude Code's own
66
+ * readable step line (`task_summary.detail`, `post_turn_summary.status_detail`), which is an
67
+ * extra field on an existing kind rather than a new kind, the grammar's rule for additions.
68
+ */
69
+ export type JobTrailDraft =
70
+ | { kind: 'tool'; verb: SessionStreamVerb; target: string; detail: string; call?: string }
71
+ | { kind: 'prose'; text: string }
72
+ | { kind: 'status'; state: 'working'; tool_outcome?: ToolOutcome; detail?: string; needs_action?: string }
73
+
74
+ export const JOB_TRAIL_VERBS: readonly SessionStreamVerb[] = ['read', 'edit', 'write', 'bash', 'search', 'task', 'other']
75
+
76
+ /** Per prose entry. The app keeps 2,000 of a Sessions prose (its own cap), so the trail
77
+ * journals no more than a client will ever show. */
78
+ export const TRAIL_PROSE_MAX_CHARS = 2_000
79
+
80
+ /** Claude Code's step and summary lines are one sentence; this is room for two. */
81
+ export const TRAIL_STATUS_MAX_CHARS = 160
82
+
83
+ const CALL_ID_RE = /^[A-Za-z0-9_-]{4,96}$/
84
+ const CONTROL_EXCEPT_NEWLINE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
85
+
86
+ function asRecord(value: unknown): Record<string, unknown> | null {
87
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
88
+ }
89
+
90
+ function str(value: unknown): string {
91
+ return typeof value === 'string' ? value : ''
92
+ }
93
+
94
+ function callId(value: unknown): string | undefined {
95
+ return typeof value === 'string' && CALL_ID_RE.test(value) ? value : undefined
96
+ }
97
+
98
+ function lineCount(value: unknown): number {
99
+ if (typeof value !== 'string') return 0
100
+ const text = value.replace(/\n+$/, '')
101
+ return text.length === 0 ? 0 : text.split('\n').length
102
+ }
103
+
104
+ function linesDetail(value: unknown): string {
105
+ const lines = lineCount(value)
106
+ return lines === 0 ? 'no output' : `${lines} line${lines === 1 ? '' : 's'}`
107
+ }
108
+
109
+ function parseRecord(line: string): Record<string, unknown> | null {
110
+ const trimmed = typeof line === 'string' ? line.trim() : ''
111
+ if (!trimmed || trimmed[0] !== '{') return null
112
+ try {
113
+ return asRecord(JSON.parse(trimmed))
114
+ } catch {
115
+ return null
116
+ }
117
+ }
118
+
119
+ // ─── The user's opt-out ───
120
+
121
+ export type JobTrailToolMode = 'off' | 'status' | 'preview'
122
+
123
+ /** A failed step's detail that is DERIVED (a status or a count), never output text. */
124
+ const DERIVED_FAILURE_DETAIL_RE = /^(?:|exit -?\d+|denied|interrupted|timeout|not found|failed|error)$/
125
+
126
+ /**
127
+ * The job's `activityToolMode`, applied to one draft. Null means drop it.
128
+ *
129
+ * off prose only. Every `tool` draft goes, every step outcome, and Claude Code's
130
+ * own step line and `needs_action` too, since each of them names a step. The
131
+ * user turned tool activity off; the agent's own words stay.
132
+ * status steps with their verb and short target (a basename, a command summary, a
133
+ * pattern: the grammar never carries raw input), and outcomes as derived
134
+ * tokens only. A failure whose detail is text the tool wrote is shown as
135
+ * `error`, because that text is raw output.
136
+ * preview the full grammar, including the first words of a failure.
137
+ *
138
+ * Every mode still passes through `sanitizeJobTrailDraft` before the journal.
139
+ */
140
+ export function applyTrailToolMode(draft: JobTrailDraft, mode: JobTrailToolMode): JobTrailDraft | null {
141
+ if (draft.kind === 'prose') return draft
142
+ // QA round 1: Claude Code's step line (`detail`) and `needs_action` name the step too
143
+ // ("Running npm test"), so `off` keeps the agent's own words and nothing else.
144
+ if (mode === 'off') return null
145
+ if (mode === 'preview') return draft
146
+ if (draft.kind === 'status' && draft.tool_outcome && !draft.tool_outcome.ok
147
+ && !DERIVED_FAILURE_DETAIL_RE.test(draft.tool_outcome.detail)) {
148
+ return { ...draft, tool_outcome: { ...draft.tool_outcome, detail: 'error' } }
149
+ }
150
+ return draft
151
+ }
152
+
153
+ // ─── The journal boundary ───
154
+
155
+ /** The bounded head a redaction reads, carriage returns folded and control bytes out. */
156
+ function scannable(value: unknown): string {
157
+ if (typeof value !== 'string' || value.length === 0) return ''
158
+ const { head } = boundForRedaction(value)
159
+ return head.replace(/\r\n?/g, '\n').replace(CONTROL_EXCEPT_NEWLINE_RE, ' ')
160
+ }
161
+
162
+ /** Both redaction layers, in order, on raw text: private-material blocks and provider
163
+ * secrets (activity-preview), then the store's credential and local-path patterns. Runs
164
+ * BEFORE any flattening or cutting, so a multi-line key block is caught whole and a secret
165
+ * is never half-cut into a visible prefix. */
166
+ function redacted(value: unknown): string {
167
+ const plain = scannable(value)
168
+ return plain ? redactQueryJobText(redactSecretText(plain)) : ''
169
+ }
170
+
171
+ /** Secrets out and nothing else: for a path the caller will shorten to its basename (the
172
+ * local-path layer would turn the whole path into `[path]` first). */
173
+ function secretFree(value: unknown): string {
174
+ const plain = scannable(value)
175
+ return plain ? redactSecretText(plain) : ''
176
+ }
177
+
178
+ function trailLine(value: unknown, max: number): string {
179
+ return oneLine(redacted(value), max)
180
+ }
181
+
182
+ function trailBlock(value: unknown, max: number): string {
183
+ const text = redacted(value).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
184
+ if (text.length <= max) return text
185
+ return text.slice(0, Math.max(0, max - TRUNCATION_MARK.length)).trimEnd() + TRUNCATION_MARK
186
+ }
187
+
188
+ function sanitizeOutcome(raw: unknown): ToolOutcome | undefined {
189
+ const r = asRecord(raw)
190
+ if (!r || typeof r.ok !== 'boolean') return undefined
191
+ const call = callId(r.call)
192
+ return { ok: r.ok, detail: trailLine(r.detail, DETAIL_MAX_CHARS), ...(call ? { call } : {}) }
193
+ }
194
+
195
+ /**
196
+ * The only shape a trail row may have in the journal, from anything.
197
+ *
198
+ * Every text field is redacted (`redacted`) and then capped; `verb` is held to the closed
199
+ * set; a call id must look like one. Idempotent, so hydration runs it again on every row a
200
+ * journal holds and a rule added later applies to rows written earlier. Null means the row
201
+ * says nothing worth keeping (empty prose, a lifecycle status, an unnamed step).
202
+ */
203
+ export function sanitizeJobTrailDraft(raw: unknown): JobTrailDraft | null {
204
+ const r = asRecord(raw)
205
+ if (!r) return null
206
+ if (r.kind === 'prose') {
207
+ const text = trailBlock(r.text, TRAIL_PROSE_MAX_CHARS)
208
+ return text ? { kind: 'prose', text } : null
209
+ }
210
+ if (r.kind === 'tool') {
211
+ const verb = JOB_TRAIL_VERBS.includes(r.verb as SessionStreamVerb) ? r.verb as SessionStreamVerb : 'other'
212
+ const target = trailLine(r.target, TARGET_MAX_CHARS)
213
+ if (!target) return null
214
+ const call = callId(r.call)
215
+ return { kind: 'tool', verb, target, detail: trailLine(r.detail, DETAIL_MAX_CHARS), ...(call ? { call } : {}) }
216
+ }
217
+ if (r.kind === 'status') {
218
+ if (r.state !== 'working') return null
219
+ const outcome = sanitizeOutcome(r.tool_outcome)
220
+ const detail = trailLine(r.detail, TRAIL_STATUS_MAX_CHARS)
221
+ const needs = trailLine(r.needs_action, TRAIL_STATUS_MAX_CHARS)
222
+ if (!outcome && !detail && !needs) return null
223
+ return {
224
+ kind: 'status',
225
+ state: 'working',
226
+ ...(outcome ? { tool_outcome: outcome } : {}),
227
+ ...(detail ? { detail } : {}),
228
+ ...(needs ? { needs_action: needs } : {}),
229
+ }
230
+ }
231
+ return null
232
+ }
233
+
234
+ /** A journaled trail event's data back into its parts, or null (a malformed row). */
235
+ export function parseJobTrailEventData(raw: unknown): { trailSeq: number; draft: JobTrailDraft } | null {
236
+ const r = asRecord(raw)
237
+ if (!r) return null
238
+ const trailSeq = r.trailSeq
239
+ if (typeof trailSeq !== 'number' || !Number.isSafeInteger(trailSeq) || trailSeq < 1) return null
240
+ const { trailSeq: _trailSeq, ...rest } = r
241
+ const draft = sanitizeJobTrailDraft(rest)
242
+ return draft ? { trailSeq, draft } : null
243
+ }
244
+
245
+ /** Characters of text a trail entry holds, for the snapshot's size bound. */
246
+ export function jobTrailTextChars(draft: JobTrailDraft): number {
247
+ if (draft.kind === 'prose') return draft.text.length
248
+ if (draft.kind === 'tool') return draft.target.length + draft.detail.length
249
+ return (draft.detail?.length ?? 0) + (draft.needs_action?.length ?? 0) + (draft.tool_outcome?.detail.length ?? 0)
250
+ }
251
+
252
+ // ─── Readers: one stdout line to zero or more drafts ───
253
+
254
+ export interface JobTrailReader {
255
+ /** One complete stdout line. Never throws. */
256
+ line(line: string): void
257
+ /** End of stream: flush anything held for coalescing. */
258
+ end(): void
259
+ }
260
+
261
+ /**
262
+ * The Sessions grammar's output, narrowed to what a trail may carry.
263
+ *
264
+ * `prompt` is the ONE place a person's words could enter the trail, and it never does:
265
+ * the job's own prompt echo (and, from Phase 4, a replayed steer with its attachment block,
266
+ * image paths or the ULTRACODE keyword) is dropped here, whatever uuid it carries.
267
+ */
268
+ function fromGrammar(draft: SessionStreamDraft): JobTrailDraft | null {
269
+ switch (draft.kind) {
270
+ case 'tool':
271
+ return { kind: 'tool', verb: draft.verb, target: draft.target, detail: draft.detail, ...(draft.call ? { call: draft.call } : {}) }
272
+ case 'prose':
273
+ return { kind: 'prose', text: draft.text }
274
+ case 'status':
275
+ // Lifecycle (`init` working, `result` done) is the job's; only an outcome rides.
276
+ return draft.tool_outcome ? { kind: 'status', state: 'working', tool_outcome: draft.tool_outcome } : null
277
+ case 'prompt':
278
+ return null
279
+ default:
280
+ return null
281
+ }
282
+ }
283
+
284
+ /** Tool-input keys a step's TARGET is read from (see `targetForTool`), and the path keys
285
+ * that are shortened to a basename. Every other key (file contents, edit strings) only
286
+ * ever yields a count, so it is left as is. */
287
+ const TARGET_TEXT_KEYS = ['command', 'cmd', 'pattern', 'query', 'skill', 'description', 'subject', 'prompt', 'url'] as const
288
+ const TARGET_PATH_KEYS = ['file_path', 'path', 'notebook_path'] as const
289
+
290
+ function preRedactToolInput(input: unknown): unknown {
291
+ const args = asRecord(input)
292
+ if (!args) return input
293
+ const out: Record<string, unknown> = { ...args }
294
+ for (const key of TARGET_PATH_KEYS) if (typeof args[key] === 'string') out[key] = secretFree(args[key])
295
+ for (const key of TARGET_TEXT_KEYS) if (typeof args[key] === 'string') out[key] = redacted(args[key])
296
+ return out
297
+ }
298
+
299
+ /**
300
+ * A Claude record with every field the grammar turns into VISIBLE text redacted in full,
301
+ * before the grammar cuts it: text blocks (prose), a tool_use's target fields, and an
302
+ * error result's text (the first words of a failure). A successful result is left whole:
303
+ * the grammar reads only counts from it, and bounding it would change those counts.
304
+ */
305
+ function preRedactClaudeRecord(record: Record<string, unknown>): Record<string, unknown> {
306
+ const message = asRecord(record.message)
307
+ if (!message || !Array.isArray(message.content)) return record
308
+ const content = message.content.map(raw => {
309
+ const block = asRecord(raw)
310
+ if (!block) return raw
311
+ if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: redacted(block.text) }
312
+ if (block.type === 'tool_use') return { ...block, input: preRedactToolInput(block.input) }
313
+ if (block.type === 'tool_result' && block.is_error === true) {
314
+ if (typeof block.content === 'string') return { ...block, content: redacted(block.content) }
315
+ if (Array.isArray(block.content)) {
316
+ return { ...block, content: block.content.map(part => {
317
+ const p = asRecord(part)
318
+ return p && typeof p.text === 'string' ? { ...p, text: redacted(p.text) } : part
319
+ }) }
320
+ }
321
+ }
322
+ return raw
323
+ })
324
+ return { ...record, message: { ...message, content } }
325
+ }
326
+
327
+ /**
328
+ * Claude (`claude -p --output-format stream-json --verbose`).
329
+ *
330
+ * Measured on Claude Code 2.1.278 (canary capture, 2026-09-19): every content block is its
331
+ * OWN `assistant` record, and the blocks of one model message share `message.id`. So prose
332
+ * is held per message id and flushed at the first line that is not more text of the same
333
+ * message: one prose entry per assistant message, never one per streamed delta (deltas are
334
+ * `stream_event` rows, which only ever flush). `post_turn_summary` arrives before `result`.
335
+ *
336
+ * A subagent's records carry `parent_tool_use_id`; its steps are shown, its prose is not
337
+ * (those are not the agent's words to the user).
338
+ */
339
+ function createClaudeReader(emit: (draft: JobTrailDraft) => void): JobTrailReader {
340
+ let pending: { key: string; text: string } | null = null
341
+ let lastStatus = ''
342
+ const flush = () => {
343
+ if (!pending) return
344
+ const text = pending.text
345
+ pending = null
346
+ emit({ kind: 'prose', text })
347
+ }
348
+ const status = (detail: string, needs: string) => {
349
+ if (!detail && !needs) return
350
+ const key = `${detail}\n${needs}`
351
+ // Claude Code repeats a task summary while one step runs; the trail says it once.
352
+ if (key === lastStatus) return
353
+ lastStatus = key
354
+ emit({ kind: 'status', state: 'working', ...(detail ? { detail } : {}), ...(needs ? { needs_action: needs } : {}) })
355
+ }
356
+ return {
357
+ line(raw) {
358
+ const record = parseRecord(raw)
359
+ if (!record) return
360
+ const type = str(record.type)
361
+ if (type === 'stream_event') {
362
+ const inner = asRecord(record.event)
363
+ const innerType = str(inner?.type)
364
+ const block = asRecord(inner?.content_block)
365
+ if (innerType === 'message_start' || innerType === 'message_stop'
366
+ || (innerType === 'content_block_start' && block?.type !== 'text')) flush()
367
+ return
368
+ }
369
+ if (type === 'assistant') {
370
+ const message = asRecord(record.message)
371
+ const key = str(message?.id)
372
+ const subagent = str(record.parent_tool_use_id).length > 0
373
+ for (const draft of draftsFromRecord('claude', preRedactClaudeRecord(record))) {
374
+ const mapped = fromGrammar(draft)
375
+ if (!mapped) continue
376
+ if (mapped.kind === 'prose') {
377
+ if (subagent) continue
378
+ if (pending && (!key || pending.key !== key)) flush()
379
+ pending = pending ? { key, text: `${pending.text}\n\n${mapped.text}` } : { key, text: mapped.text }
380
+ continue
381
+ }
382
+ flush()
383
+ emit(mapped)
384
+ }
385
+ return
386
+ }
387
+ flush()
388
+ if (type === 'user') {
389
+ for (const draft of draftsFromRecord('claude', preRedactClaudeRecord(record))) {
390
+ const mapped = fromGrammar(draft)
391
+ if (mapped) emit(mapped)
392
+ }
393
+ return
394
+ }
395
+ if (type === 'system') {
396
+ if (record.subtype === 'task_summary') {
397
+ status(trailLine(record.detail, TRAIL_STATUS_MAX_CHARS), '')
398
+ } else if (record.subtype === 'post_turn_summary') {
399
+ status(trailLine(record.status_detail, TRAIL_STATUS_MAX_CHARS), trailLine(record.needs_action, TRAIL_STATUS_MAX_CHARS))
400
+ }
401
+ }
402
+ // `result`, `init`, `rate_limit_event`, `thinking_tokens`: the job status is authoritative.
403
+ },
404
+ end: flush,
405
+ }
406
+ }
407
+
408
+ // The shell-wrapper and outcome rules are the Sessions grammar's (it reads the same live
409
+ // `codex exec --json` stream for a Continue turn), shared rather than copied.
410
+ const codexCommandText = codexExecCommandText
411
+ const codexCommandOutcome = codexExecCommandOutcome
412
+
413
+ function outcomeDraft(outcome: Omit<ToolOutcome, 'call'>, call?: string): JobTrailDraft {
414
+ return { kind: 'status', state: 'working', tool_outcome: { ...outcome, ...(call ? { call } : {}) } }
415
+ }
416
+
417
+ /**
418
+ * Codex (`codex exec --json`).
419
+ *
420
+ * Field names measured from the shipped binary's serde strings (ChatGPT.app's `codex`,
421
+ * 2026-09-19), and for the lifecycle events, `agent_message` and `command_execution`
422
+ * confirmed by a real 0.155.0 run (`__fixtures__/codex-exec-json-0.155.0.jsonl`; the other
423
+ * item kinds are still the serde-string reading): events `item.started|updated|completed`, items `agent_message`, `reasoning`,
424
+ * `command_execution` (`command`, `aggregated_output`, `exit_code`, `status`), `file_change`
425
+ * (`changes[{path,kind: add|delete|update}]`, `status`), `mcp_tool_call` (`server`, `tool`,
426
+ * `status`, `error`), `web_search` (`query`), `todo_list`. The `{type, item:{...}}` nesting is
427
+ * the one the bridge and its tests already read. An agent message is complete when it
428
+ * arrives, so it is one prose entry as is. Each step is announced once per item id, at
429
+ * start when there is one, and its outcome follows at completion. Anything else is dropped,
430
+ * `reasoning` by contract and newer item kinds conservatively.
431
+ *
432
+ * A row that is not an exec item event (an older `{msg:{...}}` wrapper, a rollout row) goes
433
+ * through the Sessions grammar, which reads those shapes.
434
+ */
435
+ function createCodexReader(emit: (draft: JobTrailDraft) => void): JobTrailReader {
436
+ const announced = new Set<string>()
437
+ const announce = (id: string, steps: JobTrailDraft[]) => {
438
+ if (id) {
439
+ if (announced.has(id)) return
440
+ announced.add(id)
441
+ }
442
+ for (const step of steps) emit(step)
443
+ }
444
+ const item = (eventType: string, raw: Record<string, unknown>) => {
445
+ const id = str(raw.id)
446
+ const call = callId(id)
447
+ const done = eventType === 'item.completed'
448
+ switch (str(raw.type)) {
449
+ case 'agent_message': {
450
+ // Bounded before anything else reads it: the journal boundary redacts this text, and
451
+ // no regex is ever handed an unbounded message.
452
+ const text = boundForRedaction(str(raw.text)).head
453
+ if (done && text.trim()) emit({ kind: 'prose', text })
454
+ return
455
+ }
456
+ case 'command_execution': {
457
+ const command = oneLine(commandSummary(redacted(codexCommandText(raw.command))), TARGET_MAX_CHARS)
458
+ announce(id, [{ kind: 'tool', verb: 'bash', target: command || 'shell', detail: '', ...(call ? { call } : {}) }])
459
+ if (done) emit(outcomeDraft(codexCommandOutcome(raw), call))
460
+ return
461
+ }
462
+ case 'file_change': {
463
+ const changes = (Array.isArray(raw.changes) ? raw.changes : [])
464
+ .map(asRecord)
465
+ .filter((change): change is Record<string, unknown> => change !== null)
466
+ .slice(0, 5)
467
+ announce(id, changes.map((change): JobTrailDraft => {
468
+ const name = oneLine(basename(secretFree(change.path)), TARGET_MAX_CHARS) || 'file'
469
+ if (change.kind === 'add') return { kind: 'tool', verb: 'write', target: name, detail: '' }
470
+ if (change.kind === 'delete') return { kind: 'tool', verb: 'other', target: oneLine(`delete ${name}`, TARGET_MAX_CHARS), detail: '' }
471
+ return { kind: 'tool', verb: 'edit', target: name, detail: '' }
472
+ }))
473
+ if (done) {
474
+ const failed = raw.status === 'failed'
475
+ for (const change of changes) {
476
+ const detail = failed ? 'failed' : change.kind === 'add' ? 'created' : change.kind === 'delete' ? 'deleted' : 'edited'
477
+ emit(outcomeDraft({ ok: !failed, detail }))
478
+ }
479
+ }
480
+ return
481
+ }
482
+ case 'mcp_tool_call': {
483
+ const name = [str(raw.server), str(raw.tool)].filter(Boolean).join('.')
484
+ announce(id, [{ kind: 'tool', verb: 'other', target: trailLine(name, TARGET_MAX_CHARS) || 'mcp tool', detail: '', ...(call ? { call } : {}) }])
485
+ if (done) {
486
+ const failed = raw.status === 'failed' || (raw.error != null && raw.error !== '')
487
+ emit(outcomeDraft({ ok: !failed, detail: failed ? 'failed' : '' }, call))
488
+ }
489
+ return
490
+ }
491
+ case 'web_search': {
492
+ const query = trailLine(raw.query, TARGET_MAX_CHARS)
493
+ // A search can start before its query is known; announce it once the query is.
494
+ if (!query && !done) return
495
+ announce(id, [{ kind: 'tool', verb: 'search', target: query || 'web search', detail: '', ...(call ? { call } : {}) }])
496
+ if (done) emit(outcomeDraft({ ok: true, detail: '' }, call))
497
+ return
498
+ }
499
+ default:
500
+ return
501
+ }
502
+ }
503
+ return {
504
+ line(raw) {
505
+ const record = parseRecord(raw)
506
+ if (!record) return
507
+ const type = str(record.type)
508
+ if (type === 'item.started' || type === 'item.updated' || type === 'item.completed') {
509
+ const body = asRecord(record.item)
510
+ if (body) item(type, body)
511
+ return
512
+ }
513
+ // `thread.started`, `turn.started|completed|failed`: lifecycle, the job's.
514
+ if (type.includes('.')) return
515
+ for (const draft of draftsFromRecord('codex', record)) {
516
+ const mapped = fromGrammar(draft)
517
+ if (mapped) emit(mapped)
518
+ }
519
+ },
520
+ end() {},
521
+ }
522
+ }
523
+
524
+ /** Cursor tool-call keys (canonical protobuf JSON of `ToolCall`, measured from the
525
+ * 2026.09.15 agent bundle's descriptors) to the grammar's tool names. Anything absent is an
526
+ * `other` step named by its key. */
527
+ const CURSOR_TOOL_NAMES: Readonly<Record<string, string>> = {
528
+ shellToolCall: 'Bash',
529
+ piBashToolCall: 'Bash',
530
+ editToolCall: 'Edit',
531
+ piEditToolCall: 'Edit',
532
+ applyAgentDiffToolCall: 'Edit',
533
+ piWriteToolCall: 'Write',
534
+ readToolCall: 'Read',
535
+ piReadToolCall: 'Read',
536
+ grepToolCall: 'Grep',
537
+ piGrepToolCall: 'Grep',
538
+ globToolCall: 'Glob',
539
+ piFindToolCall: 'Glob',
540
+ semSearchToolCall: 'Search',
541
+ webSearchToolCall: 'WebSearch',
542
+ webFetchToolCall: 'WebFetch',
543
+ taskToolCall: 'Task',
544
+ }
545
+
546
+ const CURSOR_FILE_TOOLS = new Set(['Read', 'Edit', 'Write'])
547
+
548
+ function cursorStep(key: string, args: Record<string, unknown>, call?: string): JobTrailDraft {
549
+ const canonical = CURSOR_TOOL_NAMES[key]
550
+ const readable = key.replace(/ToolCall$/, '')
551
+ if (!canonical) {
552
+ const named = key === 'mcpToolCall' ? str(args.toolName) || str(args.name) : ''
553
+ return { kind: 'tool', verb: 'other', target: trailLine(named || readable, TARGET_MAX_CHARS) || 'tool', detail: '', ...(call ? { call } : {}) }
554
+ }
555
+ // Redacted in full before `targetForTool` shortens them.
556
+ const input: Record<string, unknown> = {
557
+ ...(CURSOR_FILE_TOOLS.has(canonical) ? { file_path: secretFree(str(args.path) || str(args.filePath) || str(args.targetFile)) } : {}),
558
+ command: redacted(str(args.command)),
559
+ pattern: redacted(str(args.pattern) || str(args.globPattern) || str(args.query) || str(args.searchTerm) || str(args.url)),
560
+ description: redacted(str(args.description)),
561
+ }
562
+ const target = targetForTool(canonical, input) || readable
563
+ return { kind: 'tool', verb: verbForToolName(canonical), target, detail: '', ...(call ? { call } : {}) }
564
+ }
565
+
566
+ /** A Cursor result oneof (`success`, `failure` with `exitCode`, `timeout`, `rejected`,
567
+ * `permissionDenied`, `fileNotFound`, `error`, ...) to a derived token. Never output text. */
568
+ function cursorOutcome(key: string, result: unknown): Omit<ToolOutcome, 'call'> {
569
+ const r = asRecord(result)
570
+ if (!r) return { ok: true, detail: '' }
571
+ const success = asRecord(r.success)
572
+ if (success) return { ok: true, detail: CURSOR_TOOL_NAMES[key] === 'Bash' ? linesDetail(success.stdout) : '' }
573
+ const failure = asRecord(r.failure)
574
+ if (failure) return { ok: false, detail: typeof failure.exitCode === 'number' ? `exit ${failure.exitCode}` : 'failed' }
575
+ if ('timeout' in r) return { ok: false, detail: 'timeout' }
576
+ if ('rejected' in r || 'permissionDenied' in r || 'readPermissionDenied' in r || 'writePermissionDenied' in r) {
577
+ return { ok: false, detail: 'denied' }
578
+ }
579
+ if ('fileNotFound' in r) return { ok: false, detail: 'not found' }
580
+ if ('error' in r || 'spawnError' in r || 'invalidFile' in r) return { ok: false, detail: 'error' }
581
+ return { ok: true, detail: '' }
582
+ }
583
+
584
+ /**
585
+ * Cursor (`agent -p --output-format stream-json --stream-partial-output`).
586
+ *
587
+ * Measured from the 2026.09.15 agent bundle: with partial output on, every text delta is
588
+ * an `assistant` record WITH `timestamp_ms` and no `model_call_id`; right before each
589
+ * `tool_call started` the CLI flushes the whole segment as one `assistant` record carrying
590
+ * `model_call_id`, and before `result` it flushes the last segment with NO `timestamp_ms`.
591
+ * Those two flush records are the coalesced prose: exactly one entry per segment, and the
592
+ * deltas are never read. (A flush before a `retry` record carries a timestamp and no model
593
+ * call id, the same as a delta, so the trail skips that one segment; the answer is not
594
+ * affected.)
595
+ *
596
+ * The prompt echo (a `user` record), `thinking` records and lifecycle rows are dropped.
597
+ */
598
+ function createCursorReader(emit: (draft: JobTrailDraft) => void): JobTrailReader {
599
+ const announced = new Set<string>()
600
+ return {
601
+ line(raw) {
602
+ const record = parseRecord(raw)
603
+ if (!record) return
604
+ const type = str(record.type)
605
+ if (type === 'assistant') {
606
+ const segment = (record.model_call_id != null && record.model_call_id !== '') || typeof record.timestamp_ms !== 'number'
607
+ if (!segment) return
608
+ for (const draft of draftsFromRecord('cursor', preRedactClaudeRecord(record))) {
609
+ if (draft.kind === 'prose') emit({ kind: 'prose', text: draft.text })
610
+ }
611
+ return
612
+ }
613
+ if (type !== 'tool_call') return
614
+ const toolCall = asRecord(record.tool_call)
615
+ if (!toolCall) return
616
+ const key = Object.keys(toolCall).find(name => /ToolCall$/.test(name))
617
+ if (!key) return
618
+ const body = asRecord(toolCall[key]) ?? {}
619
+ const call = callId(record.call_id)
620
+ const seen = call ? announced.has(call) : false
621
+ if (record.subtype === 'started') {
622
+ if (seen) return
623
+ if (call) announced.add(call)
624
+ emit(cursorStep(key, asRecord(body.args) ?? {}, call))
625
+ return
626
+ }
627
+ if (record.subtype === 'completed') {
628
+ if (call && !seen) {
629
+ announced.add(call)
630
+ emit(cursorStep(key, asRecord(body.args) ?? {}, call))
631
+ }
632
+ emit(outcomeDraft(cursorOutcome(key, body.result), call))
633
+ }
634
+ },
635
+ end() {},
636
+ }
637
+ }
638
+
639
+ export function createJobTrailReader(provider: JobTrailProvider, emit: (draft: JobTrailDraft) => void): JobTrailReader {
640
+ if (provider === 'codex') return createCodexReader(emit)
641
+ if (provider === 'cursor') return createCursorReader(emit)
642
+ return createClaudeReader(emit)
643
+ }
644
+
645
+ // ─── The tee ───
646
+
647
+ /**
648
+ * Process-wide counters for `/api/health` (`messages_trail`): stdout lines the readers were
649
+ * handed, entries they emitted, and failures they swallowed (a reader that throws, or a
650
+ * consumer that does). Counts and a timestamp only, never text.
651
+ */
652
+ const trailStats = { linesRead: 0, entriesEmitted: 0, readerErrors: 0, lastErrorAt: null as number | null }
653
+
654
+ function noteTrailError(): void {
655
+ trailStats.readerErrors++
656
+ trailStats.lastErrorAt = Date.now()
657
+ }
658
+
659
+ export interface JobTrailStats {
660
+ linesRead: number
661
+ entriesEmitted: number
662
+ readerErrors: number
663
+ lastErrorAt: string | null
664
+ }
665
+
666
+ export function jobTrailStats(): JobTrailStats {
667
+ return {
668
+ linesRead: trailStats.linesRead,
669
+ entriesEmitted: trailStats.entriesEmitted,
670
+ readerErrors: trailStats.readerErrors,
671
+ lastErrorAt: trailStats.lastErrorAt === null ? null : new Date(trailStats.lastErrorAt).toISOString(),
672
+ }
673
+ }
674
+
675
+ export function __resetJobTrailStatsForTests(): void {
676
+ trailStats.linesRead = 0
677
+ trailStats.entriesEmitted = 0
678
+ trailStats.readerErrors = 0
679
+ trailStats.lastErrorAt = null
680
+ }
681
+
682
+ /** The part of a child's stdout the tee uses. A Node Readable fits, and so does the plain
683
+ * EventEmitter the bridge tests fake a child with. */
684
+ export interface JobTrailStream {
685
+ on(event: string, listener: (...args: any[]) => void): unknown
686
+ }
687
+
688
+ /**
689
+ * Attach the trail as a SECOND reader of a bridge's stdout.
690
+ *
691
+ * Call it AFTER the bridge registers its own `data` listener, so the bridge sees every chunk
692
+ * first and its behaviour cannot depend on this one. With no `onTrail` it registers nothing,
693
+ * which is how every caller without a trail consumer keeps exactly the 6.51.0 stream. The
694
+ * listener never throws into the stream: a bad line costs its own drafts and nothing else.
695
+ */
696
+ export function teeJobTrail(
697
+ stream: JobTrailStream | null | undefined,
698
+ provider: JobTrailProvider,
699
+ onTrail?: (draft: JobTrailDraft) => void,
700
+ ): void {
701
+ if (!onTrail || !stream) return
702
+ const emit = (draft: JobTrailDraft) => {
703
+ try {
704
+ onTrail(draft)
705
+ trailStats.entriesEmitted++
706
+ } catch {
707
+ /* the trail is observation; it never affects the run that produced it */
708
+ noteTrailError()
709
+ }
710
+ }
711
+ const reader = createJobTrailReader(provider, emit)
712
+ const decoder = new StringDecoder('utf8')
713
+ const assembler = createLineAssembler()
714
+ let ended = false
715
+ const readLine = (line: string) => {
716
+ trailStats.linesRead++
717
+ try { reader.line(line) } catch { noteTrailError() /* one line */ }
718
+ }
719
+ const feed = (text: string) => {
720
+ for (const line of assembler.push(text)) readLine(line)
721
+ }
722
+ stream.on('data', (chunk: unknown) => {
723
+ if (ended) return
724
+ try {
725
+ feed(typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? decoder.write(chunk) : '')
726
+ } catch { noteTrailError() /* one chunk */ }
727
+ })
728
+ const finish = () => {
729
+ if (ended) return
730
+ ended = true
731
+ try {
732
+ feed(decoder.end())
733
+ for (const line of assembler.flush()) readLine(line)
734
+ reader.end()
735
+ } catch { noteTrailError() /* end of an observation */ }
736
+ }
737
+ stream.on('end', finish)
738
+ stream.on('close', finish)
739
+ }
740
+
741
+ // ─── Ollama: the server owns the loop, so the bridge calls these directly ───
742
+
743
+ /** One Ollama tool call as a step. The COS tools are searches and a meeting read. */
744
+ export function ollamaTrailStep(name: string, args: Record<string, unknown>): JobTrailDraft {
745
+ if (name === 'search_meetings' || name === 'search_memories') {
746
+ return { kind: 'tool', verb: 'search', target: trailLine(args.query, TARGET_MAX_CHARS) || name, detail: '' }
747
+ }
748
+ if (name === 'read_meeting') {
749
+ return { kind: 'tool', verb: 'read', target: oneLine(basename(secretFree(args.filename)), TARGET_MAX_CHARS) || name, detail: '' }
750
+ }
751
+ return { kind: 'tool', verb: 'other', target: trailLine(name, TARGET_MAX_CHARS) || 'tool', detail: '' }
752
+ }
753
+
754
+ /** Its outcome: the tools answer an error as `{"error": ...}` or `unknown tool ...`. */
755
+ export function ollamaTrailOutcome(result: string): JobTrailDraft {
756
+ const failed = /^\s*\{\s*"error"\s*:/.test(result) || /^unknown tool\b/.test(result)
757
+ return outcomeDraft({ ok: !failed, detail: failed ? 'error' : '' })
758
+ }