@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.
- package/.env.example +16 -0
- package/CHANGELOG.md +78 -0
- package/README.md +56 -3
- package/bin/hooks/cos-session-hook +24 -1
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +83 -5
- 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-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/codex-live-queue-deps.ts +135 -0
- package/server/lib/codex-live-queue.ts +232 -0
- package/server/lib/codex-turn-clock.ts +36 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/cursor-stop-followup.ts +134 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/occupancy-probes.ts +52 -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 +600 -15
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/lib/thread-occupancy.ts +30 -7
- package/server/routes/agent-session-bindings.ts +98 -6
- package/server/routes/agent-sessions.ts +24 -2
- package/server/routes/cursor-stop-followup.ts +74 -0
- package/server/routes/health.ts +34 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
- 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,
|
|
582
|
+
* Codex: three input shapes, and within a ROLLOUT one channel per kind.
|
|
583
583
|
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
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
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
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`
|
|
596
|
-
*
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|