@gotcos/glasses-server 6.50.4 → 6.52.0

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