@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
@@ -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, with ONE CHANNEL PER KIND, which is the point.
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
- * Codex writes the same assistant text twice, as `event_msg/agent_message` AND as
585
- * `response_item/message` with `role:'assistant'` (measured on this Mac: 6 of each in
586
- * one rollout, plus 8 `response_item/agent_message`). Mapping both would double every
587
- * reply on the lens. So prose comes from the event channel only and tools from the
588
- * response-item channel only, and the duplication is unrepresentable rather than
589
- * deduplicated after the fact.
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
- * The honest cost: if a Codex build stops emitting `event_msg/agent_message`, prose
592
- * goes quiet and the poll fallback carries the text. Quiet is the safe direction;
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
- * `codex exec --json` has also historically wrapped events as `{id, msg:{type,...}}`
596
- * rather than `{type:'event_msg', payload:{...}}`. Both are accepted.
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') return draftsFromCodexEvent(payload)
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
- if (provider === 'codex') return draftsFromCodexRecord(obj)
655
- // Cursor shares Claude's content-block shape, keyed off `role` instead of `type`.
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
- if (draft.kind === 'status') verdict = draft.state === 'done' ? { ended: true, reason: 'codex_complete' } : { ended: false, reason: 'prompt_open' }
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
  }