@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.
- package/.env.example +16 -0
- package/CHANGELOG.md +86 -0
- package/README.md +56 -3
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +58 -9
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-instance-claim.ts +22 -1
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +629 -18
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/routes/agent-sessions.ts +12 -1
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +14 -7
- package/server/routes/health.ts +24 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
|
@@ -37,7 +37,7 @@ export type SessionStreamDraft =
|
|
|
37
37
|
// ADDITIVE TO A CLOSED SET, AND SAFE BY CONSTRUCTION: the client validates `kind`
|
|
38
38
|
// against its own table and ignores anything it does not know, so a build that
|
|
39
39
|
// predates this renders exactly as it did before rather than breaking.
|
|
40
|
-
| { kind: 'prompt'; text: string }
|
|
40
|
+
| { kind: 'prompt'; text: string; turn_started_at?: string }
|
|
41
41
|
| { kind: 'prose'; text: string }
|
|
42
42
|
// 6.49.0: `tool_outcome` rides on a status draft, never on a new kind. A shipped
|
|
43
43
|
// client drops an unknown kind BEFORE seq accounting and paints a gap row for it
|
|
@@ -173,6 +173,35 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
173
173
|
: null
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/** ISO time the current turn actually started, from the transcript record. */
|
|
177
|
+
export function recordTimestampIso(record: Record<string, unknown>): string | undefined {
|
|
178
|
+
const nested = asRecord(record.payload)
|
|
179
|
+
const item = asRecord(record.item)
|
|
180
|
+
const candidates = [record.timestamp, record.ts, nested?.timestamp, nested?.ts, item?.timestamp]
|
|
181
|
+
for (const c of candidates) {
|
|
182
|
+
if (typeof c === 'string' && c.trim()) {
|
|
183
|
+
const t = Date.parse(c)
|
|
184
|
+
if (Number.isFinite(t) && t > 0) return new Date(t).toISOString()
|
|
185
|
+
}
|
|
186
|
+
if (typeof c === 'number' && Number.isFinite(c) && c > 0) {
|
|
187
|
+
const ms = c < 1e12 ? c * 1000 : c
|
|
188
|
+
if (Number.isFinite(ms) && ms > 0) return new Date(ms).toISOString()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stampPromptTurnStart(drafts: SessionStreamDraft[], record: Record<string, unknown>): SessionStreamDraft[] {
|
|
195
|
+
const iso = recordTimestampIso(record)
|
|
196
|
+
if (!iso) return drafts
|
|
197
|
+
let stamped = false
|
|
198
|
+
return drafts.map((draft) => {
|
|
199
|
+
if (draft.kind !== 'prompt' || stamped) return draft
|
|
200
|
+
stamped = true
|
|
201
|
+
return { ...draft, turn_started_at: iso }
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
176
205
|
function countLines(value: unknown): number {
|
|
177
206
|
if (typeof value !== 'string' || value.length === 0) return 0
|
|
178
207
|
return value.split('\n').length
|
|
@@ -579,34 +608,76 @@ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamD
|
|
|
579
608
|
}
|
|
580
609
|
|
|
581
610
|
/**
|
|
582
|
-
* Codex,
|
|
611
|
+
* Codex: three input shapes, and within a ROLLOUT one channel per kind.
|
|
612
|
+
*
|
|
613
|
+
* 1. A rollout (`~/.codex/sessions/.../rollout-*.jsonl`, what the transcript watcher
|
|
614
|
+
* tails). It writes the same assistant text on more than one channel: as a TurnItem
|
|
615
|
+
* (`event_msg/item_completed`, item `AgentMessage`) or, in an older rollout, as the
|
|
616
|
+
* legacy `event_msg/agent_message`, AND as `response_item/message` with
|
|
617
|
+
* `role:'assistant'` (and, on 0.155, a `response_item/agent_message` as well). It
|
|
618
|
+
* writes each command twice too: as the `response_item` call AND as an
|
|
619
|
+
* `item_completed` `CommandExecution` / `FileChange` (a 0.155 rollout here: 41 of
|
|
620
|
+
* each). Mapping two channels would double every line on the lens. So, for a rollout:
|
|
621
|
+
*
|
|
622
|
+
* prose event channel only: `item_completed/AgentMessage`, else the legacy
|
|
623
|
+
* `agent_message`. Never `response_item/message` or `/agent_message`.
|
|
624
|
+
* tools response-item channel only. Never `CommandExecution` / `FileChange`.
|
|
625
|
+
* status event channel only (task_started / task_complete / turn_aborted).
|
|
626
|
+
*
|
|
627
|
+
* 2. The live `codex exec --json` stream (what a COS-driven Continue turn prints, read by
|
|
628
|
+
* the attached-turn stream). MEASURED on codex-cli 0.155.0 (2026-09-19, fixture
|
|
629
|
+
* `__fixtures__/codex-exec-json-0.155.0.jsonl`): `{type:'thread.started'}`,
|
|
630
|
+
* `{type:'turn.started'}`, then `item.started` / `item.completed` wrapping
|
|
631
|
+
* `{id, type, ...}` with snake_case item types (`agent_message`, `command_execution`,
|
|
632
|
+
* ...), then `{type:'turn.completed', usage}`. Each item appears once per event, keyed
|
|
633
|
+
* by its id; there is no second channel. See `draftsFromCodexExecEvent`.
|
|
634
|
+
*
|
|
635
|
+
* 3. The `{id, msg:{type,...}}` envelope of older `codex exec` builds, read exactly as
|
|
636
|
+
* 6.51.0 read it: the legacy events only (task_started, agent_message, task_complete).
|
|
583
637
|
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
638
|
+
* WHY THE TWO PROSE SOURCES CANNOT BOTH FIRE IN ONE ROLLOUT (6.52.0). Codex's rollout
|
|
639
|
+
* writer (`should_persist_event_msg`, codex-rs/rollout/src/policy.rs) persists the
|
|
640
|
+
* legacy `AgentMessage` event ONLY when the thread's `history_mode` is `legacy`, and an
|
|
641
|
+
* `ItemCompleted` AgentMessage ONLY when it is `paginated`; every earlier version of
|
|
642
|
+
* that policy persisted `ItemCompleted` for plan items alone. `history_mode` is a
|
|
643
|
+
* property of the thread, stamped on its `session_meta`. Measured on this Mac on
|
|
644
|
+
* 2026-09-19: 912 rollouts from codex-cli 0.128 to 0.155, 949 of 949 `session_meta`
|
|
645
|
+
* records say `paginated`, 0 legacy `agent_message` events, 171,606 `item_completed`
|
|
646
|
+
* events, 0 rollouts with both, and no AgentMessage item id written twice.
|
|
590
647
|
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* doubled text is not.
|
|
648
|
+
* Until 6.52.0 prose came from `agent_message` alone, so on every rollout above the
|
|
649
|
+
* lens said "Working. Nothing written yet." while the Codex app showed paragraphs.
|
|
594
650
|
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
651
|
+
* The older `{id, msg:{type,...}}` exec envelope reads the legacy events alone, exactly as
|
|
652
|
+
* 6.51.0 did, and never an item: no build on this Mac still writes it, so there is nothing
|
|
653
|
+
* measured to change that reader against. Today's exec stream is shape 2 above.
|
|
597
654
|
*/
|
|
598
655
|
function draftsFromCodexRecord(record: Record<string, unknown>): SessionStreamDraft[] {
|
|
599
656
|
const msg = asRecord(record.msg)
|
|
600
657
|
if (msg && typeof msg.type === 'string') return draftsFromCodexEvent(msg)
|
|
601
658
|
|
|
602
659
|
const type = typeof record.type === 'string' ? record.type : ''
|
|
660
|
+
// Shape 2: a live `codex exec --json` event. No rollout row type contains a dot.
|
|
661
|
+
if (type === 'thread.started' || type.startsWith('turn.') || type.startsWith('item.')) return draftsFromCodexExecEvent(record)
|
|
603
662
|
const payload = asRecord(record.payload)
|
|
604
663
|
if (!payload) return []
|
|
605
|
-
if (type === 'event_msg')
|
|
664
|
+
if (type === 'event_msg') {
|
|
665
|
+
if (payload.type === 'item_completed') return draftsFromCodexItem(asRecord(payload.item))
|
|
666
|
+
return draftsFromCodexEvent(payload)
|
|
667
|
+
}
|
|
606
668
|
if (type !== 'response_item') return []
|
|
607
669
|
|
|
608
670
|
const kind = typeof payload.type === 'string' ? payload.type : ''
|
|
609
671
|
if (kind === 'function_call' || kind === 'custom_tool_call' || kind === 'local_shell_call') {
|
|
672
|
+
const name = typeof payload.name === 'string' ? payload.name.trim() : ''
|
|
673
|
+
// A code-mode cell (6.52.0): `exec` carrying JavaScript that calls the real tools.
|
|
674
|
+
if (kind === 'custom_tool_call' && name === 'exec' && typeof payload.input === 'string' && isCodeModeCell(payload.input)) {
|
|
675
|
+
return codeModeDrafts(payload.input, payload.call_id)
|
|
676
|
+
}
|
|
677
|
+
if (name === 'apply_patch') {
|
|
678
|
+
const patch = typeof payload.input === 'string' ? payload.input : patchFromArguments(payload.arguments)
|
|
679
|
+
if (patch !== null) return [patchDraft(patch, payload.call_id)]
|
|
680
|
+
}
|
|
610
681
|
// `arguments` is a JSON STRING on function_call; `input` is a raw string on
|
|
611
682
|
// custom_tool_call. Only the parseable one can yield a structured target.
|
|
612
683
|
let input: unknown = payload.input
|
|
@@ -619,11 +690,130 @@ function draftsFromCodexRecord(record: Record<string, unknown>): SessionStreamDr
|
|
|
619
690
|
} else if (typeof input === 'string') {
|
|
620
691
|
input = { command: input }
|
|
621
692
|
}
|
|
693
|
+
if (kind === 'function_call' && isCodexPoll(name, input)) return []
|
|
622
694
|
return [toolDraft(payload.name, input, payload.call_id)]
|
|
623
695
|
}
|
|
624
696
|
return []
|
|
625
697
|
}
|
|
626
698
|
|
|
699
|
+
/** `codex exec` wraps each command in a login shell: `/bin/zsh -lc 'npm test'`. The
|
|
700
|
+
* command is what ran; the wrapper is how. Shared with the Messages trail's Codex reader. */
|
|
701
|
+
const CODEX_SHELL_WRAPPER_RE = /^\s*(?:\S*\/)?(?:ba|z|da)?sh\s+-l?c\s+(?:'([\s\S]*)'|"([\s\S]*)")\s*$/
|
|
702
|
+
|
|
703
|
+
export function codexExecCommandText(value: unknown): string {
|
|
704
|
+
if (Array.isArray(value)) {
|
|
705
|
+
const parts = value.filter((part): part is string => typeof part === 'string')
|
|
706
|
+
if (parts.length >= 3 && /(?:^|\/)(?:ba|z|da)?sh$/.test(parts[0]) && /^-l?c$/.test(parts[1])) return parts.slice(2).join(' ')
|
|
707
|
+
return parts.join(' ')
|
|
708
|
+
}
|
|
709
|
+
const text = typeof value === 'string' ? value : ''
|
|
710
|
+
const match = CODEX_SHELL_WRAPPER_RE.exec(text)
|
|
711
|
+
return match ? (match[1] ?? match[2] ?? text) : text
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function execLineCount(value: unknown): number {
|
|
715
|
+
if (typeof value !== 'string') return 0
|
|
716
|
+
const text = value.replace(/\n+$/, '')
|
|
717
|
+
return text.length === 0 ? 0 : text.split('\n').length
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** A finished `command_execution` item to a derived outcome token. Never output text.
|
|
721
|
+
* Shared with the Messages trail's Codex reader. */
|
|
722
|
+
export function codexExecCommandOutcome(item: Record<string, unknown>): Omit<ToolOutcome, 'call'> {
|
|
723
|
+
const exit = item.exit_code
|
|
724
|
+
if (typeof exit === 'number') {
|
|
725
|
+
if (exit !== 0) return { ok: false, detail: `exit ${exit}` }
|
|
726
|
+
const lines = execLineCount(item.aggregated_output)
|
|
727
|
+
return { ok: true, detail: lines === 0 ? 'no output' : `${lines} line${lines === 1 ? '' : 's'}` }
|
|
728
|
+
}
|
|
729
|
+
if (item.status === 'declined') return { ok: false, detail: 'denied' }
|
|
730
|
+
if (item.status === 'failed') return { ok: false, detail: 'failed' }
|
|
731
|
+
return { ok: true, detail: '' }
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function execOutcome(outcome: Omit<ToolOutcome, 'call'>, call?: string): SessionStreamDraft {
|
|
735
|
+
return { kind: 'status', state: 'working', tool_outcome: call ? { ...outcome, call } : outcome }
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* One live `codex exec --json` event (6.52.0 QA round 1: a Continue that runs through
|
|
740
|
+
* `codex exec` streamed only working and done, because nothing read this shape).
|
|
741
|
+
*
|
|
742
|
+
* STATELESS, so each step is emitted exactly once by rule rather than by memory:
|
|
743
|
+
* turn.started status working. turn.completed / turn.failed: status done.
|
|
744
|
+
* agent_message prose, at completion (it arrives complete).
|
|
745
|
+
* reasoning the reasoning headline on a status, at completion; never prose.
|
|
746
|
+
* command_execution the step at START (it has one, measured), its outcome at
|
|
747
|
+
* completion, paired by the item id.
|
|
748
|
+
* mcp_tool_call the same.
|
|
749
|
+
* web_search, file_change the step and its outcome at COMPLETION only: a search's query
|
|
750
|
+
* is not known at start, and a file change is reported done.
|
|
751
|
+
* thread.started, item.updated, anything newer: nothing.
|
|
752
|
+
*/
|
|
753
|
+
function draftsFromCodexExecEvent(record: Record<string, unknown>): SessionStreamDraft[] {
|
|
754
|
+
const type = typeof record.type === 'string' ? record.type : ''
|
|
755
|
+
if (type === 'turn.started') return [{ kind: 'status', state: 'working' }]
|
|
756
|
+
if (type === 'turn.completed' || type === 'turn.failed') return [{ kind: 'status', state: 'done' }]
|
|
757
|
+
if (type !== 'item.started' && type !== 'item.completed') return []
|
|
758
|
+
const item = asRecord(record.item)
|
|
759
|
+
if (!item) return []
|
|
760
|
+
const done = type === 'item.completed'
|
|
761
|
+
const call = callId(item.id)
|
|
762
|
+
switch (item.type) {
|
|
763
|
+
case 'agent_message': {
|
|
764
|
+
if (!done) return []
|
|
765
|
+
const prose = proseDraft(item.text)
|
|
766
|
+
return prose ? [prose] : []
|
|
767
|
+
}
|
|
768
|
+
case 'reasoning': {
|
|
769
|
+
if (!done) return []
|
|
770
|
+
const reasoning = codexReasoningHeadline([item.text])
|
|
771
|
+
if (!reasoning) return []
|
|
772
|
+
const draft: CodexReasoningStatus = { kind: 'status', state: 'working', reasoning }
|
|
773
|
+
return [draft]
|
|
774
|
+
}
|
|
775
|
+
case 'command_execution':
|
|
776
|
+
return done
|
|
777
|
+
? [execOutcome(codexExecCommandOutcome(item), call)]
|
|
778
|
+
: [toolDraft('Bash', { command: codexExecCommandText(item.command) }, item.id)]
|
|
779
|
+
case 'mcp_tool_call': {
|
|
780
|
+
if (done) {
|
|
781
|
+
const failed = item.status === 'failed' || (item.error != null && item.error !== '')
|
|
782
|
+
return [execOutcome({ ok: !failed, detail: failed ? 'failed' : '' }, call)]
|
|
783
|
+
}
|
|
784
|
+
const name = [item.server, item.tool].filter((part): part is string => typeof part === 'string' && part.length > 0).join('.')
|
|
785
|
+
return [{ kind: 'tool', verb: 'other', target: oneLine(name, TARGET_MAX_CHARS) || 'mcp tool', detail: '', ...(call ? { call } : {}) }]
|
|
786
|
+
}
|
|
787
|
+
case 'web_search': {
|
|
788
|
+
if (!done) return []
|
|
789
|
+
const query = oneLine(item.query, TARGET_MAX_CHARS)
|
|
790
|
+
return [
|
|
791
|
+
{ kind: 'tool', verb: 'search', target: query || 'web search', detail: '', ...(call ? { call } : {}) },
|
|
792
|
+
execOutcome({ ok: true, detail: '' }, call),
|
|
793
|
+
]
|
|
794
|
+
}
|
|
795
|
+
case 'file_change': {
|
|
796
|
+
if (!done) return []
|
|
797
|
+
const failed = item.status === 'failed'
|
|
798
|
+
const changes = (Array.isArray(item.changes) ? item.changes : [])
|
|
799
|
+
.map(asRecord)
|
|
800
|
+
.filter((change): change is Record<string, unknown> => change !== null)
|
|
801
|
+
.slice(0, 5)
|
|
802
|
+
const out: SessionStreamDraft[] = []
|
|
803
|
+
for (const change of changes) {
|
|
804
|
+
const name = oneLine(basename(change.path), TARGET_MAX_CHARS) || 'file'
|
|
805
|
+
if (change.kind === 'add') out.push({ kind: 'tool', verb: 'write', target: name, detail: '' })
|
|
806
|
+
else if (change.kind === 'delete') out.push({ kind: 'tool', verb: 'other', target: oneLine(`delete ${name}`, TARGET_MAX_CHARS), detail: '' })
|
|
807
|
+
else out.push({ kind: 'tool', verb: 'edit', target: name, detail: '' })
|
|
808
|
+
out.push(execOutcome({ ok: !failed, detail: failed ? 'failed' : change.kind === 'add' ? 'created' : change.kind === 'delete' ? 'deleted' : 'edited' }))
|
|
809
|
+
}
|
|
810
|
+
return out
|
|
811
|
+
}
|
|
812
|
+
default:
|
|
813
|
+
return []
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
627
817
|
function draftsFromCodexEvent(payload: Record<string, unknown>): SessionStreamDraft[] {
|
|
628
818
|
const kind = typeof payload.type === 'string' ? payload.type : ''
|
|
629
819
|
if (kind === 'task_started') return [{ kind: 'status', state: 'working' }]
|
|
@@ -637,6 +827,427 @@ function draftsFromCodexEvent(payload: Record<string, unknown>): SessionStreamDr
|
|
|
637
827
|
return []
|
|
638
828
|
}
|
|
639
829
|
|
|
830
|
+
/**
|
|
831
|
+
* A `status: working` draft carrying Codex's reasoning headline (6.52.0).
|
|
832
|
+
*
|
|
833
|
+
* AN EXTRA FIELD ON AN EXISTING KIND, NEVER A NEW KIND, for the reason `tool_outcome`
|
|
834
|
+
* rides a status draft: a shipped client drops an unknown `kind` BEFORE its seq
|
|
835
|
+
* accounting and paints a gap row, while it takes a repeated `status: working` as a
|
|
836
|
+
* no-op and strips the fields it does not know. So an old lens renders exactly what
|
|
837
|
+
* it did before, and a client that learns `reasoning` can show the line. It is never
|
|
838
|
+
* prose: it is the model's summary of what it is thinking, not something it wrote.
|
|
839
|
+
*/
|
|
840
|
+
export type CodexReasoningStatus = { kind: 'status'; state: 'working'; reasoning: string }
|
|
841
|
+
|
|
842
|
+
export function isCodexReasoningStatus(draft: SessionStreamDraft): boolean {
|
|
843
|
+
return draft.kind === 'status' && typeof (draft as { reasoning?: unknown }).reasoning === 'string'
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* One completed Codex TurnItem (`event_msg/item_completed`, paginated rollouts).
|
|
848
|
+
*
|
|
849
|
+
* Measured item types on this Mac: AgentMessage, Reasoning, UserMessage,
|
|
850
|
+
* CommandExecution, FileChange, WebSearch, McpToolCall, ContextCompaction, Plan,
|
|
851
|
+
* SubAgentActivity, ImageView. Only the first three map; the tool items are the
|
|
852
|
+
* response-item channel's job (see `draftsFromCodexRecord`).
|
|
853
|
+
*/
|
|
854
|
+
function draftsFromCodexItem(item: Record<string, unknown> | null): SessionStreamDraft[] {
|
|
855
|
+
if (!item) return []
|
|
856
|
+
if (item.type === 'AgentMessage') {
|
|
857
|
+
// `content: [{type:'Text', text}]`, `phase: 'commentary' | 'final_answer'`. Both are
|
|
858
|
+
// what Codex wrote to the person, so both are prose.
|
|
859
|
+
const prose = proseDraft(codexItemText(item.content))
|
|
860
|
+
return prose ? [prose] : []
|
|
861
|
+
}
|
|
862
|
+
if (item.type === 'Reasoning') {
|
|
863
|
+
const reasoning = codexReasoningHeadline(item.summary_text)
|
|
864
|
+
if (!reasoning) return []
|
|
865
|
+
const draft: CodexReasoningStatus = { kind: 'status', state: 'working', reasoning }
|
|
866
|
+
return [draft]
|
|
867
|
+
}
|
|
868
|
+
if (item.type === 'UserMessage') {
|
|
869
|
+
// Codex already leaves AGENTS.md, `<environment_context>` and skill bodies out of
|
|
870
|
+
// this item (they are response_item rows only), so what is left is what the person
|
|
871
|
+
// sent, less automation wrappers and attached-file scaffolding.
|
|
872
|
+
const blocks = Array.isArray(item.content)
|
|
873
|
+
? item.content.map(block => asRecord(block)?.text)
|
|
874
|
+
: []
|
|
875
|
+
const ask = codexUserAsk(blocks)
|
|
876
|
+
const text = ask === null ? '' : oneLine(ask, PROMPT_MAX_CHARS)
|
|
877
|
+
return text ? [{ kind: 'prompt', text }] : []
|
|
878
|
+
}
|
|
879
|
+
return []
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function codexItemText(content: unknown): string {
|
|
883
|
+
if (!Array.isArray(content)) return ''
|
|
884
|
+
return content
|
|
885
|
+
.map(block => asRecord(block)?.text)
|
|
886
|
+
.filter((text): text is string => typeof text === 'string' && text.trim().length > 0)
|
|
887
|
+
.join('\n\n')
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* The reasoning line: the headline of the newest summary section, markdown bold out.
|
|
892
|
+
*
|
|
893
|
+
* `summary_text` is a list of sections, each `**Title**` or `**Title**\n\nbody`
|
|
894
|
+
* (415,343 sections measured, no other form). The newest is what the model is
|
|
895
|
+
* thinking about now; its title is the glanceable part. An empty list (reasoning that
|
|
896
|
+
* was encrypted, 4,988 items) yields nothing rather than a placeholder.
|
|
897
|
+
*/
|
|
898
|
+
export function codexReasoningHeadline(summary: unknown): string {
|
|
899
|
+
if (!Array.isArray(summary)) return ''
|
|
900
|
+
for (let k = summary.length - 1; k >= 0; k--) {
|
|
901
|
+
const section = summary[k]
|
|
902
|
+
if (typeof section !== 'string' || section.trim().length === 0) continue
|
|
903
|
+
// The first line IS the title (`**Title**`); a body, when there is one, follows a
|
|
904
|
+
// blank line.
|
|
905
|
+
const plain = oneLine(section.trim().split('\n')[0].replace(/\*\*/g, ''), TARGET_MAX_CHARS)
|
|
906
|
+
if (plain) return plain
|
|
907
|
+
}
|
|
908
|
+
return ''
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* What the person asked, out of the text blocks of one Codex user message, or null.
|
|
913
|
+
*
|
|
914
|
+
* Shared by the stream (the `prompt` line) and the session detail (title, first
|
|
915
|
+
* prompt, DISCUSSION, recent turns), so the two can never disagree about what a user
|
|
916
|
+
* said. Per BLOCK, because a Codex user message is a list of blocks and one record can
|
|
917
|
+
* hold a wrapper block next to typed text (`<image>` then the words; 5 records here).
|
|
918
|
+
*
|
|
919
|
+
* DROPPED, measured over this Mac's rollouts on 2026-09-19:
|
|
920
|
+
* - `# AGENTS.md instructions for <dir>`: Codex writes the repo's AGENTS.md as a user
|
|
921
|
+
* message. It is the FIRST user row in 469 rollouts, so it became the session's
|
|
922
|
+
* title and opening DISCUSSION line.
|
|
923
|
+
* - any block that opens with a tag: `<environment_context>`, `<skill>`,
|
|
924
|
+
* `<subagent_notification>`, `<recommended_plugins>`, `<heartbeat>`, `<image ...>`,
|
|
925
|
+
* `</image>`, `<turn_aborted>` and the rest. The same rule `isWrapperPrompt` uses.
|
|
926
|
+
*
|
|
927
|
+
* KEPT, because it is real: an attached-file message (`# Files mentioned by the user:`,
|
|
928
|
+
* `# Files pasted by the user:`, `# In app browser:`) is reduced to the words after its
|
|
929
|
+
* `## My request for Codex:` / `## My request:` heading, which is what was typed
|
|
930
|
+
* (1,648 of 1,760 such messages). One without that heading is kept whole.
|
|
931
|
+
*/
|
|
932
|
+
export function codexUserAsk(blocks: readonly unknown[]): string | null {
|
|
933
|
+
const kept: string[] = []
|
|
934
|
+
for (const block of blocks) {
|
|
935
|
+
if (typeof block !== 'string') continue
|
|
936
|
+
let text = block.trim()
|
|
937
|
+
if (!text || text.startsWith('<') || CODEX_AGENTS_MD.test(text)) continue
|
|
938
|
+
if (CODEX_ATTACHMENT_HEADER.test(text)) {
|
|
939
|
+
const request = CODEX_REQUEST_HEADING.exec(text)
|
|
940
|
+
if (request) text = text.slice(request.index + request[0].length).trim()
|
|
941
|
+
}
|
|
942
|
+
if (text) kept.push(text)
|
|
943
|
+
}
|
|
944
|
+
return kept.length > 0 ? kept.join('\n\n') : null
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const CODEX_AGENTS_MD = /^#\s*AGENTS\.md instructions\b/
|
|
948
|
+
const CODEX_ATTACHMENT_HEADER = /^#\s*(?:Files mentioned by the user|Files pasted by the user|In app browser)\b/
|
|
949
|
+
const CODEX_REQUEST_HEADING = /^##\s*My request(?: for Codex)?:/m
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* A poll, which is not a step (6.52.0).
|
|
953
|
+
*
|
|
954
|
+
* Code mode runs a command in a cell and then WAITS on it: `wait` with a `cell_id`, or
|
|
955
|
+
* `write_stdin` with no characters to read more output (10,828 of 11,041 `write_stdin`
|
|
956
|
+
* calls measured). Each rendered as a step ("other wait", "other write_stdin"), so the
|
|
957
|
+
* lens counted and listed waiting as work. A `write_stdin` that TYPES something is an
|
|
958
|
+
* action and stays a step.
|
|
959
|
+
*/
|
|
960
|
+
function isCodexPoll(name: string, input: unknown): boolean {
|
|
961
|
+
if (name === 'wait') return true
|
|
962
|
+
if (name !== 'write_stdin') return false
|
|
963
|
+
const chars = asRecord(input)?.chars
|
|
964
|
+
return chars === undefined || chars === null || chars === ''
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Steps one code-mode cell may add. A cell that loops over calls is still one glance. */
|
|
968
|
+
export const CODE_MODE_MAX_STEPS = 3
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Is this `exec` input a code-mode JavaScript cell rather than a shell command?
|
|
972
|
+
*
|
|
973
|
+
* Every `exec` custom tool call on this Mac (48,706, codex-cli 0.129 to 0.155) is
|
|
974
|
+
* JavaScript: it calls `tools.<fn>(...)`, or opens with a declaration, `text(`,
|
|
975
|
+
* `ALL_TOOLS`, a loop, a comment or an array. Anything else keeps the old reading,
|
|
976
|
+
* a shell command, so an older build's plain `exec` renders exactly as before.
|
|
977
|
+
*/
|
|
978
|
+
function isCodeModeCell(input: string): boolean {
|
|
979
|
+
if (/^\s*(?:(?:const|let|var|await)\b|text\s*\(|ALL_TOOLS\b|for\s*\(|\/\/|\/\*|\[\s*["'`\d[{])/.test(input)) return true
|
|
980
|
+
return lexCodeModeCell(input).calls.length > 0
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
interface CodeModeCall { fn: string; args: string | null }
|
|
984
|
+
|
|
985
|
+
function isIdentChar(ch: string | undefined): boolean {
|
|
986
|
+
return ch !== undefined && /[\w$]/.test(ch)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function skipSpace(src: string, i: number): number {
|
|
990
|
+
while (i < src.length && /\s/.test(src[i])) i++
|
|
991
|
+
return i
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Index just past the string literal opening at `i` (or the end, if it never closes). */
|
|
995
|
+
function skipStringLiteral(src: string, i: number): number {
|
|
996
|
+
const quote = src[i]
|
|
997
|
+
let j = i + 1
|
|
998
|
+
while (j < src.length) {
|
|
999
|
+
const ch = src[j]
|
|
1000
|
+
if (ch === '\\') { j += 2; continue }
|
|
1001
|
+
if (ch === quote) return j + 1
|
|
1002
|
+
j++
|
|
1003
|
+
}
|
|
1004
|
+
return src.length
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Index of the bracket closing the one at `open`, skipping strings; the end if none. */
|
|
1008
|
+
function matchBracket(src: string, open: number): number {
|
|
1009
|
+
let depth = 0
|
|
1010
|
+
let j = open
|
|
1011
|
+
while (j < src.length) {
|
|
1012
|
+
const ch = src[j]
|
|
1013
|
+
if (ch === '"' || ch === "'" || ch === '`') { j = skipStringLiteral(src, j); continue }
|
|
1014
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
1015
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
1016
|
+
depth--
|
|
1017
|
+
if (depth === 0) return j
|
|
1018
|
+
}
|
|
1019
|
+
j++
|
|
1020
|
+
}
|
|
1021
|
+
return src.length
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const JS_SIMPLE_ESCAPES: Readonly<Record<string, string>> = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' }
|
|
1025
|
+
|
|
1026
|
+
/** The value of a JS string literal (quotes included in `literal`), or null. */
|
|
1027
|
+
function decodeJsString(literal: string): string | null {
|
|
1028
|
+
if (literal.length < 2) return null
|
|
1029
|
+
const quote = literal[0]
|
|
1030
|
+
if ((quote !== '"' && quote !== "'" && quote !== '`') || literal[literal.length - 1] !== quote) return null
|
|
1031
|
+
// One reading for all three quote styles (JSON's escapes are a subset of these).
|
|
1032
|
+
return literal.slice(1, -1).replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_, esc: string) => {
|
|
1033
|
+
const simple = JS_SIMPLE_ESCAPES[esc]
|
|
1034
|
+
if (simple !== undefined) return simple
|
|
1035
|
+
if (esc.startsWith('u{')) return String.fromCodePoint(parseInt(esc.slice(2, -1), 16))
|
|
1036
|
+
if (esc.length > 1) return String.fromCharCode(parseInt(esc.slice(1), 16))
|
|
1037
|
+
return esc
|
|
1038
|
+
})
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The tool calls and string literals of a code-mode cell.
|
|
1043
|
+
*
|
|
1044
|
+
* A small lexer rather than a pattern over the source: a command string that itself
|
|
1045
|
+
* mentions `tools.apply_patch(` (a search for it, say) must not count as a call, and a
|
|
1046
|
+
* call's arguments end at ITS closing parenthesis, not at the first one in a string.
|
|
1047
|
+
*/
|
|
1048
|
+
function lexCodeModeCell(src: string): { calls: CodeModeCall[]; literals: string[] } {
|
|
1049
|
+
const calls: CodeModeCall[] = []
|
|
1050
|
+
const literals: string[] = []
|
|
1051
|
+
let i = 0
|
|
1052
|
+
while (i < src.length) {
|
|
1053
|
+
const ch = src[i]
|
|
1054
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
1055
|
+
const end = skipStringLiteral(src, i)
|
|
1056
|
+
const value = decodeJsString(src.slice(i, end))
|
|
1057
|
+
if (value !== null) literals.push(value)
|
|
1058
|
+
i = end
|
|
1059
|
+
continue
|
|
1060
|
+
}
|
|
1061
|
+
if (ch === '/' && src[i + 1] === '/') {
|
|
1062
|
+
const nl = src.indexOf('\n', i)
|
|
1063
|
+
i = nl < 0 ? src.length : nl + 1
|
|
1064
|
+
continue
|
|
1065
|
+
}
|
|
1066
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
1067
|
+
const close = src.indexOf('*/', i + 2)
|
|
1068
|
+
i = close < 0 ? src.length : close + 2
|
|
1069
|
+
continue
|
|
1070
|
+
}
|
|
1071
|
+
if (src.startsWith('tools', i) && !isIdentChar(src[i - 1]) && src[i - 1] !== '.' && !isIdentChar(src[i + 5])) {
|
|
1072
|
+
let j = skipSpace(src, i + 5)
|
|
1073
|
+
let fn: string | null = null
|
|
1074
|
+
if (src[j] === '.') {
|
|
1075
|
+
j = skipSpace(src, j + 1)
|
|
1076
|
+
const name = /^[A-Za-z_$][\w$]*/.exec(src.slice(j))
|
|
1077
|
+
if (name) { fn = name[0]; j += name[0].length }
|
|
1078
|
+
} else if (src[j] === '[') {
|
|
1079
|
+
j = skipSpace(src, j + 1)
|
|
1080
|
+
if (src[j] === '"' || src[j] === "'" || src[j] === '`') {
|
|
1081
|
+
const end = skipStringLiteral(src, j)
|
|
1082
|
+
const name = decodeJsString(src.slice(j, end))
|
|
1083
|
+
j = skipSpace(src, end)
|
|
1084
|
+
if (name && src[j] === ']') { fn = name; j += 1 }
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (fn !== null) {
|
|
1088
|
+
j = skipSpace(src, j)
|
|
1089
|
+
let args: string | null = null
|
|
1090
|
+
if (src[j] === '(') {
|
|
1091
|
+
const close = matchBracket(src, j)
|
|
1092
|
+
args = src.slice(j + 1, close)
|
|
1093
|
+
// The argument literals are scanned by the main loop too, so a patch passed
|
|
1094
|
+
// inline is found the same way as one passed through a variable.
|
|
1095
|
+
i = j + 1
|
|
1096
|
+
} else {
|
|
1097
|
+
i = j
|
|
1098
|
+
}
|
|
1099
|
+
calls.push({ fn, args })
|
|
1100
|
+
continue
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
i++
|
|
1104
|
+
}
|
|
1105
|
+
return { calls, literals }
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* The string-valued top-level properties of an object literal (`{cmd:"ls", ...}` or
|
|
1110
|
+
* `{"cmd":"ls"}`), or null when the argument is not one (a variable, a call).
|
|
1111
|
+
*/
|
|
1112
|
+
function objectLiteralStrings(args: string): Record<string, string> | null {
|
|
1113
|
+
const src = args.trim()
|
|
1114
|
+
if (!src.startsWith('{')) return null
|
|
1115
|
+
const out: Record<string, string> = {}
|
|
1116
|
+
const end = matchBracket(src, 0)
|
|
1117
|
+
let i = 1
|
|
1118
|
+
while (i < end) {
|
|
1119
|
+
i = skipSpace(src, i)
|
|
1120
|
+
if (src[i] === ',') { i++; continue }
|
|
1121
|
+
if (i >= end) break
|
|
1122
|
+
let key = ''
|
|
1123
|
+
if (src[i] === '"' || src[i] === "'" || src[i] === '`') {
|
|
1124
|
+
const close = skipStringLiteral(src, i)
|
|
1125
|
+
key = decodeJsString(src.slice(i, close)) ?? ''
|
|
1126
|
+
i = close
|
|
1127
|
+
} else {
|
|
1128
|
+
const name = /^[A-Za-z_$][\w$]*/.exec(src.slice(i))
|
|
1129
|
+
if (name) { key = name[0]; i += key.length }
|
|
1130
|
+
}
|
|
1131
|
+
i = skipSpace(src, i)
|
|
1132
|
+
if (key && src[i] === ':') {
|
|
1133
|
+
i = skipSpace(src, i + 1)
|
|
1134
|
+
if (src[i] === '"' || src[i] === "'" || src[i] === '`') {
|
|
1135
|
+
const close = skipStringLiteral(src, i)
|
|
1136
|
+
const value = decodeJsString(src.slice(i, close))
|
|
1137
|
+
if (value !== null) out[key] = value
|
|
1138
|
+
i = close
|
|
1139
|
+
continue
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
// Any other value (a number, a nested object, an expression): skip to the next
|
|
1143
|
+
// top-level comma.
|
|
1144
|
+
while (i < end && src[i] !== ',') {
|
|
1145
|
+
const ch = src[i]
|
|
1146
|
+
if (ch === '"' || ch === "'" || ch === '`') i = skipStringLiteral(src, i)
|
|
1147
|
+
else if (ch === '(' || ch === '[' || ch === '{') i = matchBracket(src, i) + 1
|
|
1148
|
+
else i++
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return out
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function isCodeModePoll(call: CodeModeCall): boolean {
|
|
1155
|
+
if (call.fn === 'wait') return true
|
|
1156
|
+
if (call.fn !== 'write_stdin') return false
|
|
1157
|
+
// Only a provable poll is dropped: an argument that is not a literal might type.
|
|
1158
|
+
const args = call.args === null ? null : objectLiteralStrings(call.args)
|
|
1159
|
+
if (args === null) return false
|
|
1160
|
+
return args.chars === undefined || args.chars === ''
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** The patch text a code-mode `apply_patch` call applied, from the cell's literals. */
|
|
1164
|
+
function patchFromLiterals(literals: readonly string[]): string | null {
|
|
1165
|
+
return literals.find(value => CODEX_PATCH_FILE.test(value)) ?? null
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function patchFromArguments(argumentsJson: unknown): string | null {
|
|
1169
|
+
if (typeof argumentsJson !== 'string') return null
|
|
1170
|
+
try {
|
|
1171
|
+
const parsed = asRecord(JSON.parse(argumentsJson))
|
|
1172
|
+
const patch = parsed?.input ?? parsed?.patch
|
|
1173
|
+
return typeof patch === 'string' ? patch : null
|
|
1174
|
+
} catch {
|
|
1175
|
+
return null
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const CODEX_PATCH_FILE = /^\*\*\* (?:Add|Update|Delete) File: /m
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* One `apply_patch` as a step: `write` when it only adds files, else `edit`; the
|
|
1183
|
+
* basenames it touched; `+added -removed` counted from the hunks, the same arithmetic
|
|
1184
|
+
* an Edit step shows.
|
|
1185
|
+
*/
|
|
1186
|
+
function patchDraft(patch: string, id?: unknown): SessionStreamDraft {
|
|
1187
|
+
const names: string[] = []
|
|
1188
|
+
let onlyAdds = true
|
|
1189
|
+
let added = 0
|
|
1190
|
+
let removed = 0
|
|
1191
|
+
for (const line of patch.split('\n')) {
|
|
1192
|
+
const header = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line.trimEnd())
|
|
1193
|
+
if (header) {
|
|
1194
|
+
if (header[1] !== 'Add') onlyAdds = false
|
|
1195
|
+
const name = basename(header[2].trim())
|
|
1196
|
+
if (name && !names.includes(name)) names.push(name)
|
|
1197
|
+
continue
|
|
1198
|
+
}
|
|
1199
|
+
// Hunk lines. Every other line of the format (`*** Begin Patch`, `@@`, context)
|
|
1200
|
+
// starts with something else.
|
|
1201
|
+
if (line.startsWith('+')) added++
|
|
1202
|
+
else if (line.startsWith('-')) removed++
|
|
1203
|
+
}
|
|
1204
|
+
const call = callId(id)
|
|
1205
|
+
return {
|
|
1206
|
+
kind: 'tool',
|
|
1207
|
+
verb: names.length > 0 && onlyAdds ? 'write' : 'edit',
|
|
1208
|
+
target: names.length > 0 ? oneLine(names.join(', '), TARGET_MAX_CHARS) : 'apply_patch',
|
|
1209
|
+
detail: added + removed > 0 ? oneLine(`+${added} -${removed}`, DETAIL_MAX_CHARS) : '',
|
|
1210
|
+
...(call ? { call } : {}),
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* A code-mode cell as the steps it took (6.52.0).
|
|
1216
|
+
*
|
|
1217
|
+
* Code mode wraps every tool in JavaScript: `const r = await
|
|
1218
|
+
* tools.exec_command({"cmd":"sed -n '1,55p' ..."}); text(r.output);`. Until 6.52.0 the
|
|
1219
|
+
* whole cell was the "command", so the lens printed `bash let{output,...rest}=awai...`.
|
|
1220
|
+
* Now each real call is a step: `exec_command` is `bash` with its `cmd`, `apply_patch`
|
|
1221
|
+
* is `edit`/`write` with the file, anything else names itself. Polls are not steps. A
|
|
1222
|
+
* cell that calls no tool is `other exec`. The call id rides the first step only, so it
|
|
1223
|
+
* still names exactly one step.
|
|
1224
|
+
*/
|
|
1225
|
+
function codeModeDrafts(input: string, id?: unknown): SessionStreamDraft[] {
|
|
1226
|
+
const { calls, literals } = lexCodeModeCell(input)
|
|
1227
|
+
const call = callId(id)
|
|
1228
|
+
if (calls.length === 0) {
|
|
1229
|
+
return [{ kind: 'tool', verb: 'other', target: 'exec', detail: '', ...(call ? { call } : {}) }]
|
|
1230
|
+
}
|
|
1231
|
+
const steps: SessionStreamDraft[] = []
|
|
1232
|
+
for (const c of calls) {
|
|
1233
|
+
if (isCodeModePoll(c)) continue
|
|
1234
|
+
steps.push(codeModeStep(c, literals))
|
|
1235
|
+
if (steps.length >= CODE_MODE_MAX_STEPS) break
|
|
1236
|
+
}
|
|
1237
|
+
return steps.map((step, index) => (index === 0 && call && step.kind === 'tool' ? { ...step, call } : step))
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function codeModeStep(call: CodeModeCall, literals: readonly string[]): SessionStreamDraft {
|
|
1241
|
+
if (call.fn === 'apply_patch') {
|
|
1242
|
+
const inline = call.args === null ? null : decodeJsString(call.args.trim())
|
|
1243
|
+
const patch = inline !== null && CODEX_PATCH_FILE.test(inline) ? inline : patchFromLiterals(literals)
|
|
1244
|
+
if (patch !== null) return patchDraft(patch)
|
|
1245
|
+
return { kind: 'tool', verb: 'edit', target: 'apply_patch', detail: '' }
|
|
1246
|
+
}
|
|
1247
|
+
const args = call.args === null ? null : objectLiteralStrings(call.args)
|
|
1248
|
+
return toolDraft(call.fn, args ?? {})
|
|
1249
|
+
}
|
|
1250
|
+
|
|
640
1251
|
/**
|
|
641
1252
|
* One parsed provider record to zero or more events.
|
|
642
1253
|
*
|
|
@@ -651,9 +1262,8 @@ export function draftsFromRecord(
|
|
|
651
1262
|
const obj = asRecord(record)
|
|
652
1263
|
if (!obj) return []
|
|
653
1264
|
try {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
return draftsFromClaudeRecord(obj)
|
|
1265
|
+
const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : draftsFromClaudeRecord(obj)
|
|
1266
|
+
return stampPromptTurnStart(drafts, obj)
|
|
657
1267
|
} catch {
|
|
658
1268
|
// A malformed record costs one line of the trail. It must never cost the stream.
|
|
659
1269
|
return []
|
|
@@ -712,7 +1322,8 @@ export function turnFromTail(provider: SessionStreamProvider, lines: readonly st
|
|
|
712
1322
|
|
|
713
1323
|
if (provider === 'codex') {
|
|
714
1324
|
for (const draft of draftsFromRecord('codex', r)) {
|
|
715
|
-
|
|
1325
|
+
// 6.52.0: a reasoning line is narration, not a turn event. It never moves the verdict.
|
|
1326
|
+
if (draft.kind === 'status' && !isCodexReasoningStatus(draft)) verdict = draft.state === 'done' ? { ended: true, reason: 'codex_complete' } : { ended: false, reason: 'prompt_open' }
|
|
716
1327
|
}
|
|
717
1328
|
continue
|
|
718
1329
|
}
|