Subagent types codex-mod:codex and project-declared roles answered by the local OpenAI Codex CLI (codex exec) instead of an Anthropic model.

codex-mod is a Claude Code plugin (a "mod", built on the early-access plugin hooks-module API) that adds subagent types answered by the OpenAI Codex CLI running on your machine instead of an Anthropic model. Claude stays the orchestrator and delegates self-contained tasks to codex exec --json, using your own ChatGPT/Codex login. Codex's commands and file changes show up in the transcript as ordinary Bash, Edit and Write rows, and its final answer comes back like any other subagent report.

Claude delegates to a Codex agent (gpt-6-luna, low), then sends it a change of plan while it works; Codex picks it up mid-turn.
Desktop acceptance is partial. Native Read/Write/Edit, model/effort titles, delivered steering and a real parent/helper completion were observed. The Coordinator receipt preserves full text and accepted-turn status, but its custom card and live redraw remain unaccepted. The host may still label a Codex child as Haiku. Interactive questions, native task placement and some expanded/reopened rows also need live checks. richRows stays off by default; app-server remains opt-in. See the verification record.
The built-in sol, sol-high and astra-audit types were removed. Replace them with project roles, or use codex-mod:codex with codex-model: and codex-effort: prompt lines. The former roles option is ignored with a warning. Existing Codex login and plugin options are retained. Finish active work and start a fresh session after updating; Desktop hooks may require quitting and reopening the application.
Claude (orchestrator)
| Agent(subagent_type = "codex-mod:codex", prompt)
v
turn.step hook --- never calls the model: no Anthropic request ---
| starts: codex exec --json (task on stdin, thread resumed on follow-ups)
v
Codex events --> native Bash / Edit / Write rows (answered by a tool.call hook,
never executed by Claude) --> final answer via SubagentHandback
models_cache.json), so they exist only while the plugin is loaded.turn.step. Each model request of a codex-mod agent passes through a streaming hook that answers it from Codex's output. No Anthropic request is made for the agent's work.Bash, Edit or Write call, with Codex's interim remarks as text.tool.call guard. Those calls carry a reserved id prefix and are answered from the result Codex already reported. They are never executed by Claude.SubagentHandback, followed by a footer line.codex exec --json. The opt-in app-server transport adds mid-turn messages and stop (see Transports).Exploration adapters accept agent commands marked agent or unifiedExecStartup. User-shell commands and later unified-exec interactions retain Bash rows. Compound and conditional Bash descriptions summarize the whole command conservatively; the original command and output stay intact.
Commands normally appear as Bash rows. With app-server, a single faithful read, file listing or search can appear as Read, Glob or Grep when Codex reports a matching commandActions entry. A literal command parser admits whole cat reads, head/tail slices whose output establishes the file total, file-only NUL-delimited find/rg --files listings, and framed ripgrep content/files/match-count searches. Fixed strings are escaped into equivalent regexes. The original command and cwd appear beside each translated row; results come from Codex's output without another read, search or shell run.
Claude Code can remove a literal cd <current host cwd> && prefix from native Bash input before dispatch. For an exact matching absolute Unix shell word, codex-mod emits that display form before binding the stored result and shows the original source command and cwd beside it. Quoted and nonmatching prefixes retain their original form. Results and real command failures remain source-reported; changed input, foreign callers and replayed rows are still refused. Debug logs identify only the guard's refusal category when a binding fails. See the isolated CLI and Desktop verification.
Pipelines, multiple actions, redirects, substitutions, unknown slice totals, directory listings, ambiguous filenames/output and unsupported options keep one Bash row. Normal exec output has no command actions and stays Bash. A streamed command and its polls produce one final row. Read/Glob/Grep are admitted in each Codex agent's own tools and protected by both guards and their refusing catches, including the placeholder agent's Read; ordinary main-thread and verified Claude subagent calls pass unchanged. An unavailable exploration tool falls back to Bash when admitted. Results retain the delivered output, including CRLF and trailing newlines; already truncated output stays Bash. An unreported Glob duration is disclosed as a 0 ms display sentinel.
File additions use Write only with complete exact content. Updates use Edit only when the source diff applies to a captured base and matches stable completion reads; several bounded exact hunks can produce ordered Edit rows. Other patches use Bash’s typed patch diff with a display-comment input, including deletion flags and both move paths when images are verified. Missing facts retain the literal reported diff. Failed/declined or unconfirmed patches use error rows; they never imply successful writes. Each source patch counts once, separately from its file/hunk rows. Plan updates use codex_plan. These rows show work Codex already did and never execute a Claude action tool. Main-list UI isolation additionally depends on the host retaining child scope; see the Batch 5 live checks below. Grep content decodes ripgrep's JSON into matching/context lines with reported paths and requested line numbers; raw JSON remains in the source record. Count mode requires actual regex-match counts (--count-matches), rather than treating matching-line counts as match totals. Empty no-match searches succeed; actual failures keep Bash error rows. Desktop grouping and expanded-row appearance still need a new-session live check.
Messages received from the coordinator appear in the Codex subagent view as display-only coordinator_message rows with the full original text, including override lines. Where the host dispatches the custom renderer, a Coordinator card shows the message first, its delivery status below, and full message/turn identifiers in dimmed details. Status distinguishes received, accepted into a specific turn, observed in Codex input, queued after a turn, rejected with the reason, and uncertain. Source UUIDs prevent repeat display after a worker restart; messages over 90,000 characters split into numbered parts. With nativeActions: false, or if tool registration fails, they appear as text headed "Coordinator message:". Acceptance confirms the RPC response, not that the model read or followed the instruction. Literal receipt/status text and ↪ notes remain as fallbacks. Reopened rows can restore their literal full-body rendering before tool registration; nonlegacy names require a matching session, cached row owner, exact tool name/ID and received receipt. Text fallback fences markup so tags and Markdown stay visible. New-session Desktop tests with bundled CLI 2.1.299 confirmed literal receipt, accepted-turn status and delivered steering, but showed generic tool details; custom-card paint and live redraw are not accepted on that host.
Experimental richRows: true prefers TodoWrite for plans when admitted, then the paired TaskCreate/TaskUpdate tools, then codex_plan. TodoWrite is disabled in the tested host; Task* admission depends on the host/model. The plugin keeps per-agent/thread snapshots and synthetic codex-plan-… IDs across restarts/resume, matching exact text occurrences and explicitly removing/creating ambiguous reordered or renamed steps. Native task rows acknowledge this display registry only: no host tasks, assignments, dependencies or hooks execute. The fallback includes the full checklist and explanation in input, result and an owned checkbox card.
With richRows, completed Codex spawn operations use completed Agent results; the child can still be running or unknown. Failed/declined spawns produce errors without a completed-child result. A synthetic codex-display-… ID offers no continuation, host task or child transcript. Full child messages can use guarded SendMessage with Codex's status/state acknowledgement; close/interrupt can use TaskStop with task_type:codex. Missing tools, ambiguous targets or missing facts use codex_tool, then full text if registration fails. Resume/list use codex_tool; wait polls produce no rows, only meaningful state/report transitions. Child identity/model/effort and plan state persist as groundwork for later mirrors; no mirror is launched.
Claude Code 2.1.302 accepted guarded TaskCreate/TaskUpdate (with supported model admission), TaskStop and SendMessage acknowledgement objects in an offline headless probe. SendMessage's declared unknown result still rejects null in its mapper; only the probed status/state object is enabled. Desktop paint, synthetic task-link usefulness, and preservation of child scope on live/reload/resume remain unverified. Follow the exact main-task sentinel check in the Batch 5 harness in a NEW Desktop session after plugin reload. If a native candidate changes the main derived list or offers broken host-task controls, use richRows:false for the display fallback and record that result.
Completed web searches use WebSearch with the actual query (all queries in order for multi-query actions), source URLs/snippets and textual or opaque results. Empty results stay empty. Durations come from source timing; an unreported duration uses an explicitly disclosed zero sentinel. Opening a page or finding a pattern uses WebFetch only when Codex reports the URL, bytes, HTTP code/text, extracted text and duration. HTTP failures retain their real code; transport failures use error rows. Missing metadata and other web actions use codex_tool with the exact action name and original results.
MCP and dynamic calls use codex_tool with nested arguments, full text, structured results and readable media metadata; encoded media stays in the owned source record. Errors remain errors. Canonical external tool substitution is blocked on Claude Code 2.1.302: $.tool.list() returns only name, description and MCP status, without input schemas or original server identity, and MCP replacement results have no declared universal contract. Matching names alone cannot establish the matrix's identity/schema binding. The plugin neither guesses that binding nor replays a tool to discover it. See the Batch 4 harness for the offline MCP server, source evidence and remaining host acceptance checks.
Native rows are admitted only when the tool is available and included in that Codex agent's own tools. An unavailable tool falls back to codex_tool, or attributed text when registration also fails. Results are checked against the supported native shapes before emission and again before answering; failed or declined operations return a native denial with Codex's actual report. Bash results retain the full combined output, including trailing newlines; an unreported exit stays unknown.
The latest run's public source records and row provenance are retained in its agent record under the existing pruneDays policy. These include source IDs, command actions/cwd/process/status/timing, exact patch kinds/moves/diffs, tool and web results, child models/states, image/review/failure data and notifications, including unsupported variants. Repeated item completions produce one row; aggregate turn diffs and terminal interactions are retained without extra action rows. Private reasoning content is excluded. This foundation preserves evidence for later native mappings; it does not add new visual mappings.
Image views use guarded Read image results with actual PNG/JPEG/GIF/WebP bytes obtained through the plugin filesystem API, bounded to 4 MiB and checked for file changes during capture. The accompanying text identifies a later file preview: it cannot establish the exact bytes Codex saw earlier. Missing, changing, oversized, unsupported images or absent Read admission get an explicit preview-unavailable report. Image generation keeps its status, revised prompt, reported result and failure in codex_tool; a real saved output can add a Read preview, without counting generation twice. Result URLs are never fetched for display.
Reasoning summaries appear as best-effort live thinking, absent from reopened native thinking history; raw reasoning content never crosses the wire. Summary streaming remains opted out; completed public summaries use existing step pacing. Commentary, partial answers, proposed plans and full review-mode text appear separately and cannot become the final handback. Actual complete structured findings can use guarded ReportFindings with richRows; review prose is never parsed into invented findings. Warnings do not fail the turn, compaction notices deduplicate against their source item, and sleep reports elapsed time without scheduling anything.
With app-server, nativeActions:true and lean:false, explicit non-secret choice questions (1–4 questions, 2–4 options each, short headers, no Other/free-text option) use an owned codex_question card. Select answers and press Send selected answers, or Cancel question. Only that explicit action sends a bound, one-time RPC response; card receipt is neither answer nor approval. An acknowledged answer can add an admitted AskUserQuestion reported-answer row with richRows. Unavailable question UI, unsupported shapes and exec questions get explicit unsupported errors. Permission approvals remain disabled by default; the owner-granted approval modes are described under Execution policy. Ended, resolved, restarted and stale requests cannot be answered by old controls. Headless -p has no human controls, so use the interactive/Desktop harness for pending questions.
nativeActions:false shows attributed text and disables interactive question requests. The offline host probe accepted Read image and ReportFindings results without executor calls or inference; AskUserQuestion was unavailable in that noninteractive host. See the Batch 6 harness and Codex UI map for contracts, coverage and remaining Desktop checks.
SubagentHandback. Outside it, the answer is delivered as plain text after one extra step.codex login). The plugin looks for it on PATH, then in common install locations (Homebrew, ~/.local/bin, bun and volta global bins, the ChatGPT and Codex desktop apps), or uses the codexPath option.From GitHub:
claude plugin marketplace add diegoaleyvag/codex-mod
claude plugin install codex-mod@codex-mod --scope user
From a local clone:
claude plugin marketplace add /path/to/codex-mod
claude plugin install codex-mod@codex-mod --scope user
Start a new session afterwards: agent types are registered at session start.
For local development, a directory marketplace can execute the clone directly; the versioned installed_plugins.json cache path alone does not identify the loaded source. After hooks-module edits, finish active work, quit and reopen Desktop, then test in a new session. A new chat or /reload-plugins alone did not load the changed diagnostic module in the tested Desktop setup.
Update:
claude plugin marketplace update codex-mod
claude plugin update codex-mod@codex-mod --scope user
Uninstall:
claude plugin uninstall codex-mod@codex-mod --scope user
claude plugin marketplace remove codex-mod
Ask Claude to delegate, or name the agent directly:
Use codex-mod:codex to add input validation to src/api.ts. Allowed paths: src/. Verify with `npm test`. Report the diff summary.
Use codex-mod:auditor (declared below) to review the diff of branch feature/x against main. Report findings only.
Codex sees only the prompt and the repository's AGENTS.md, so the prompt must carry the whole task: branch, allowed paths, how to verify, what to report.
Choose a catalog model directly with an Agent type such as codex-mod:gpt-6-1-sol on the current host, or use codex-mod:codex with your configured defaults. With modelAgents:true (the default), session start registers one type per model with visibility:list in Codex's models_cache.json; its description lists supported reasoning efforts. The plugin first tries the exact slug; Claude Code 2.1.302 rejects dots in type names, so it logs a fallback with punctuation replaced by hyphens (gpt-6.1-sol → gpt-6-1-sol). The Codex model ID stays exact. Project roles also win collisions with these aliases. These types use the model's catalog effort default and defaultSandbox. If the catalog is unreadable, only the generic agent and project roles are offered. Project roles win name collisions.
The coordinator can pass the native Agent effort parameter:
{"subagent_type":"codex-mod:gpt-6-1-sol","effort":"high","description":"Check validation","prompt":"Review src/api.ts. Allowed paths: none. Report findings."}
Effort precedence is prompt codex-effort: > Agent effort > role/model-type/generic default. Native effort is validated against the resolved Codex model. An unsupported native value uses the agent's default (or that model's supported catalog default if necessary), with a visible note. The current host exposes Agent input effort to tool.call but omits it from agent.spawn; codex-mod binds the input by the real tool-use ID and also detects a future spawn effort field. If neither surface supplies it, defaults apply. The debug log records spawn.effort and Agent.effort; see the Batch 7 harness.
Project roles provide named model, effort, sandbox and instruction defaults. Leading prompt lines remain the portable fallback, including efforts the host's Agent schema cannot spell (such as ultra):
codex-model: gpt-6-luna
codex-effort: low
Summarise what src/index.ts exports.
Prompt overrides stick for follow-ups. An unknown model or unsupported prompt-line effort fails with a clear error before any Codex launch.
Launch titles read Codex <model> · <effort> · <description> for owned codex-mod spawns. The parent Agent row adds current model/effort text alongside the original native row, preserving its controls, status and navigation. The row and answer footer follow later model changes; the title records the launch choice. Resume/workflow titles are fixed by the host. Missing ownership or render failures retain the native row.
The native model badge cannot display arbitrary GPT IDs: it may still show Haiku, the unused Claude execution placeholder. codex-mod keeps registered agents on haiku; putting a GPT ID there can change provider model selection. Titles, added row text and footers are the supported model visibility sites.
Follow-ups go through SendMessage to the same agent and resume its Codex thread. A message sent while the agent is still running is delivered too, according to the transport (see Transports).
Each answer ends with a footer, for example:
codex · gpt-6-luna · low · workspace-write · 3 actions · 37.4K new tokens (154K cached, 631 out) · thread 01a14f50
It shows the agent, model, effort, sandbox, number of actions, Codex's token counts (new, cached, output) and the Codex thread id. With the app-server transport it also shows app-server and, when any were delivered, N steer(s). Failures are never silent: the answer starts with codex failed: <reason> followed by the last lines of Codex's stderr.
codex-mod:codex is always available. Its defaults come from defaultModel (empty: first model Codex lists), defaultEffort and defaultSandbox. Projects can declare additional roles in .claude/codex-mod.json, relative to the session's working directory:
{"roles": {
"implementer": {"model": "gpt-6.1-sol", "effort": "medium", "sandbox": "workspace-write", "description": "Normal bounded coding task; the prompt carries the full spec (branch, allowed paths, verification command, report format)."},
"implementer-high": {"model": "gpt-6.1-sol", "effort": "xhigh", "sandbox": "workspace-write", "description": "High-risk work or a retry after a failed attempt."},
"auditor": {"model": "gpt-6-astra", "effort": "low", "sandbox": "read-only", "description": "One read-only cross-model review of a whole change.", "instructions": "You are a read-only auditor. Never edit files. Report findings with file:line and a failure scenario."}
}}
This registers codex-mod:implementer, codex-mod:implementer-high and codex-mod:auditor when their profiles are available. There are no built-in role agents in 1.2; this example replaces the former sol, sol-high and astra-audit roles.
roles object. Each role requireshooks/codex.ts 4995 lines1// codex-mod: the generic and project-declared agent types are
2// answered by OpenAI Codex (`codex exec --json`) running on this machine,
3// never by an Anthropic model.
4//
5// How: a streaming `turn.step` hook. For a step whose loop (`e.agentId`) is an
6// agent of one of this plugin's types, the hook never calls `next(e)`, so no
7// model request is sent. It reads the agent's task from its conversation and
8// starts Codex with `$.process.spawn`. One Codex run spans several steps:
9//
10// - while Codex works, each step pulls the child's output until one of
11// Codex's actions is done (a command, a file change), then ends as a model
12// that called the matching built-in tool (`Bash`, `Write`, `Edit`) with
13// what Codex did, Codex's interim messages as text before the calls (stop
14// reason `tool_use`). With `nativeActions` off the whole run streams in
15// one step as text lines instead;
16// - those calls are NEVER executed: they carry a tool_use id with the prefix
17// `toolu_codexmod_`, and this module's `tool.call` hook answers each one
18// from the result Codex reported without calling `next`, so neither the
19// permission path nor the tool runs. Any Bash/Edit/Write/WebSearch of a Codex agent
20// (or of an agent whose type cannot be resolved) without a stored answer is
21// refused. Registered display tools refuse other callers too. The hook's
22// `.catch` refuses when the hook itself fails, and
23// `tool.check` denies such a call should it ever be reached;
24// - when Codex ends, the step hands the answer back ONCE: as the
25// `SubagentHandback` call when the conversation mentions the tool or the
26// engine sends its `[handback-send-enforce]` nudge, else as final text if
27// the API form has no signal or the session has seen a not-active result.
28// Auto still defaults to the tool on incomplete reads and falls back to
29// text on a not-active result, re-delivering the same answer, no Codex run.
30//
31// Tasks: the conversation is read in its API form, meta rows included (the
32// rows form of `$.session.messages` leaves isMeta rows out: the host's
33// SubagentHandback reminder, engine nudges, a coordinator's delivery). Engine
34// nudges and reminder-only rows are never tasks: on one, the stored last
35// answer is re-delivered. Only a genuinely new user/coordinator message
36// launches Codex, and a launch guard (one launch per message, at most
37// `maxRunsPerWindow` per agent in 10 minutes, counters in the record) backs
38// that up independently.
39//
40// Agent types are registered with `$.agent.register` at session start, so they
41// exist only while this module is loaded; models and efforts are validated
42// against Codex's own model list (`<CODEX_HOME>/models_cache.json`, read only).
43//
44// Transport (userConfig `transport`): `exec` (the default) spawns `codex exec
45// --json` with the prompt on stdin. `app-server` (opt-in) spawns our sidecar
46// `scripts/codex-runner.mjs` with node instead: it owns one `codex app-server`
47// for the run (the hook cannot keep a child's stdin open) and prints the same
48// JSONL `codex exec --json` does, plus `codexmod.*` lines (turn id, steer
49// acks, protocol drift, fatal). Everything else (pump, actions, handback,
50// footer, record, launch guard) is shared. Session start checks node, `codex
51// app-server --help` and the runner; any failure falls back to exec for the
52// session, and the footer says why. Each per-run sidecar exposes HTTP over a
53// private Unix socket. The host's socketPath API is probed before submission;
54// unsupported or policy-blocked hosts negotiate file controls. Submitted
55// operations never switch paths. Journals and lossless bodies stay in the
56// private run cache, with bounded host-store metadata.
57//
58// Steer: scoped session.receive routes early, session.append records the
59// authoritative UUID, and conversation polling remains a recovery path.
60// Successful RPC acceptance must match the expected turn; explicit rejection
61// can queue after it. Missing acknowledgments stay uncertain without replay.
62// A coordinator's SendMessage into a running turn is no user row: the engine
63// appends it as a `queued_command` attachment rendered as a reminder block
64// ("The coordinator sent a message while you were working: ...") and promotes
65// it to a system turn, so `$.session.messages` never shows it. The
66// `session.append` hook records those deliveries per agent (in memory) and the
67// next look for messages takes them; a delivery that does reach the API form
68// as a user text block (bare or wrapped) is read there as a message instead.
69// What became of a mid-run message is shown as a `↪` note: a text line in the
70// run's queue (before the next step's calls, or streamed when nativeActions is
71// off), never in place of Codex's own final answer, which it precedes.
72//
73// Budget: a hook's budget (10 s of its own time per step) stops only while a
74// `$` call OF THAT STEP is in flight. So every step pulls the child's stream
75// itself (`run.child.next()`), and no pull is ever left pending across a step
76// boundary. The 2 s check of an app-server run races the step's one pending
77// pull with a `$.clock.after` timer and waits on that same pull again; it is
78// skipped when `next.budget` runs low, and the pull always settles in the step.
79//
80// Kill behaviour: the child lives until its stream ends or is `return()`ed.
81// It is killed when `next.signal` of any step aborts (interrupt, stop), when a
82// step sees the agent is no longer running, when the agent's turn completes
83// while a run is still active, when `timeoutMinutes` elapses, and when the
84// module unloads (the engine kills every spawn of an unloaded module). An
85// app-server run is first asked to stop (the negotiated control channel) and given 1.5 s.
86//
87// Continuity: one record per agent `{ thread, model, effort, sandbox,
88// lastTask, agentName, handedBack, handback, lastAnswer, handbackLock,
89// userCount, launch-guard counters, runSeq, updatedAt }`, kept in memory and
90// in `$.store` under `agent:<sessionId>:<agentId>`, so it survives a
91// hooks-worker restart, a hot reload and a `--resume`. Records not updated for
92// `pruneDays` are pruned at session start/end. A not-active handback memo lives
93// under `session:<sessionId>:handback` in the same store with the same pruning.
94//
95// Drift: lines of an unknown type (or not JSON) are counted; a run that ends
96// with no agent message and some of them fails as "protocol changed" with the
97// codex-cli version session start read (`codex --version`; below 0.163.0 is
98// warned about once). A non-openai `model_provider` in config.toml makes a
99// failing `codex login status` a debug note instead of the logged-out blocker.
100//
101// Lean launches (userConfig `lean`, default on): `-c` overrides that turn off
102// Codex plugins, apps, skills instructions, web search, multi-agent and the
103// other tool families a worker does not need, plus every MCP server named in
104// `<CODEX_HOME>/config.toml` (read only, never written).
105//
106// Global rules (userConfig `globalRules`, default on): when off (experimental),
107// Codex runs with CODEX_HOME set to a plugin-owned folder holding symbolic
108// links to the real home's auth.json, config.toml and models_cache.json only,
109// so the user-level AGENTS.md is not loaded. The module leaves auth and config
110// files alone; optional session history changes use the Codex CLI.
111
112import { admitMirrorLaunch, mirrorHostMatches, mirrorLaunchDigest, mirrorLaunchDiagnostic, MirrorDiagnostics, isMirrorAutoModeNoVerdict, prepareMirrorRetry } from './mirror-launch.ts'
113import type { MirrorAuthority } from './mirror-launch.ts'
114import { explorationAction, validExploration, literalWords } from './exploration.ts'
115import { patchActions, PATCH_TEXT_MAX } from './patches.ts'
116import { webAction, webFallback, toolFallback, sourceDuration, validFetchResult } from './web-tools.ts'
117import { childRows, planRows, planText, emptyDisplayState } from './plans-children.ts'
118import { captureImage, validImage, generationAction, findingsAction, literalReceipt } from './images-modes.ts'
119import { QUESTION_METHOD, choiceQuestions, answeredQuestion, validAnsweredQuestion } from './questions.mjs'
120import { controlRequest, queryOutcome, publishOutcome, messageStatus } from './steer.ts'
121import type { MessageOutcome } from './steer.ts'
122import { compactStoreState, flushStoreMigrations, readStoreState, writeStoreState } from './store-state.ts'
123import { fingerprint, matchesIdentity } from './fingerprint.ts'
124import type { Fingerprint } from './fingerprint.ts'
125import { journalLine, journalRead } from './child-journal.mjs'
126import { RowCache, saveRow, loadRow } from './row-cache.ts'
127import type { RowPresentation } from './row-cache.ts'
128import { boundedNames, validCapabilities, resolvePolicy, policyTask, configHash, capacityFallback } from './run-policy.ts'
129import type { RolePolicy, EffectiveConfig } from './run-policy.ts'
130import { executionRequest, resolveExecution, executionOverrides, executionOverride, permissionEvidence, APPROVAL_MODES } from './execution-policy.ts'
131import type { ApprovalMode, ExecutionPolicy } from './execution-policy.ts'
132import { APPROVAL_METHODS, approvalRequest, approvalIdentity, matchesApproval, approvalResponse, approvalParent, parentMayDecide, fileApprovalEvidence, FILE_APPROVAL_UNAVAILABLE, commandApprovalContext, COMMAND_APPROVAL_UNAVAILABLE } from './approval-policy.mjs'
133import { agentStatus } from './agent-status.ts'
134import { execDelta, zeroUsage, validUsage } from './exec-usage.mjs'
135import { displayBashCommand } from './display-input.ts'
136import type { DisplayState, PlanInput } from './plans-children.ts'
137import type { GlobResult, GrepResult } from './exploration.ts'
138export { explorationAction, literalWords } from './exploration.ts'
139import type {
140 BuiltinToolInputs,
141 BuiltinToolResults,
142 EngineInterface,
143 HookStream,
144 ProcessSpawnChunk,
145 ProcessSpawnResult,
146 Register,
147 SessionMessage,
148 Timer,
149 TurnStepChunk,
150 TurnStepInput,
151 TurnStepResult,
152} from 'claude-code'
153
154const FALLBACK_MODEL = 'gpt-6.1-sol'
155const FALLBACK_EFFORT = 'medium'
156const SANDBOXES = ['read-only', 'workspace-write', 'danger-full-access']
157const OVERRIDE = /^\s*codex-(model|effort):[ \t]*(\S+)[ \t]*\n/i
158// A reminder block; an attachment's rendering puts an id on BOTH tags (`<system-reminder id="x">...</system-reminder id="x">`).
159const REMINDER = /<system-reminder(?:\s[^>]*)?>[\s\S]*?<\/system-reminder(?:\s[^>]*)?>/g
160const REMINDER_WRAP = /^<system-reminder(?:\s[^>]*)?>\s*([\s\S]*?)\s*<\/system-reminder(?:\s[^>]*)?>$/
161// A compaction summary or continuation notice standing as the newest user row.
162const COMPACTION = /^\s*This session is being continued from a previous conversation|<\/?compact[-_]summary>/i
163// The engine's framing of a message the coordinator sent mid-run.
164const COORDINATOR = /^\s*The coordinator sent a message while you were working:[ \t]*\n([\s\S]*?)\s*(?:\n\s*Address this before completing your current task\.?)?\s*$/
165// Any mention of the tool in the agent's conversation (meta or not, any role): tool mode.
166const HANDBACK_MENTION = /SubagentHandback/
167// The engine's nudge after a final text when SubagentHandback is active: tool mode for good.
168const ENFORCE = /^\s*\[handback-send-enforce\]/
169// The engine taking SubagentHandback away from the agent: text mode.
170const COUNTERMAND = /SubagentHandback is not available in this run/i
171// A SubagentHandback result saying nothing was sent because the tool is not the agent's.
172const NOT_ACTIVE = /not active for this agent|is not available in this run|No such tool available/i
173// An engine notice line at the edge of a user row: `[some-tag] ...` or a whole `[Sentence ...]` line.
174const NOTICE_LINE = /^\s*(?:\[[a-z]+(?:-[a-z]+)+\][^\n]*|\[[A-Z][^\n\]]{15,}\])\s*$/
175// The launch window of the anti-loop guard (`maxRunsPerWindow` launches per agent at most).
176const WINDOW_MS = 10 * 60 * 1000
177// The most re-deliveries of a stored answer without a new message (each costs no Codex run).
178const MAX_REDELIVER = 6
179// After SLOW_AFTER action steps, at most one per max(SLOW_MS, progressIntervalMs);
180// past MAX_ACTION_STEPS actions are only counted (maxTurns is 400: each step is a turn).
181const SLOW_AFTER = 60
182const SLOW_MS = 5000
183const MAX_ACTION_STEPS = 300
184const STORE_PREFIX = 'agent:'
185const SESSION_PREFIX = 'session:'
186const DAY_MS = 24 * 60 * 60 * 1000
187// In this host a subagent's result reaches its caller through this tool when
188// it is active for the agent; its input is `{ message }`.
189const HANDBACK_TOOL = 'SubagentHandback'
190/** Reserved for nonexecuting display calls, including unknown future tools. */
191export const ID_PREFIX = 'toolu_codexmod_'
192export const HANDBACK_ID_PREFIX = 'toolu_codexhandback_'
193/** The built-in tools Codex's actions are drawn as; never executed for a Codex agent. */
194export const NATIVE_TOOLS = ['Bash', 'Read', 'Glob', 'Grep', 'Edit', 'Write', 'WebSearch', 'WebFetch', 'Agent', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'SendMessage', 'TaskStop', 'ReportFindings', 'AskUserQuestion'] as const
195const RICH_TOOLS = new Set<string>(['Agent', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'SendMessage', 'TaskStop', 'ReportFindings', 'AskUserQuestion'])
196type DisplayTool = 'codex_tool' | 'codex_plan' | 'coordinator_message' | 'codex_question'
197const displayTools: Partial<Record<DisplayTool, string>> = {}
198const displayNames = new Set(['mcp__codex-mod__codex_tool', 'mcp__codex-mod__codex_plan', 'mcp__codex-mod__coordinator_message', 'mcp__codex-mod__codex_question'])
199// Kept after the guard consumes its answer, so resolved rows still render.
200const coordinatorRows = new RowCache<{ sessionId: string; tool: string; text: string; messageId?: string; outcome?: MessageOutcome }>()
201const planCards = new RowCache<PlanInput>()
202type Question = { token: string; requestId: string | number; method: string; params: any; status: string; result?: any; error?: any }
203const questionRows = new RowCache<{ session: string; agent: string; control: string; question: Question; selected: Record<string, string> }>()
204const COORDINATOR_PART_MAX = 90000
205const dimNotes = new Set<string>()
206export const NOT_EXECUTED = 'codex-mod: not executed'
207// Feature flags a lean launch turns off (`-c features.<name>=false`; an
208// unknown name is ignored by Codex).
209const LEAN_FEATURES = [
210 'plugins', 'remote_plugin', 'apps', 'multi_agent', 'image_generation', 'computer_use', 'browser_use',
211 'browser_use_external', 'in_app_browser', 'goals', 'tool_suggest', 'skill_search', 'sleep_tool',
212]
213// What a shadow CODEX_HOME links from the real one (globalRules off). Sessions
214// stay in the shadow, so `exec resume` works there (verified on codex-cli 0.163).
215export const SHADOW_LINKS = ['auth.json', 'config.toml', 'models_cache.json'] as const
216// `-c` keys extraConfig may not set: the plugin sets them per agent.
217const RESERVED_KEYS = ['model', 'model_reasoning_effort', 'sandbox_mode']
218/** Where Codex is commonly installed, tried after PATH (`~` is the home folder). */
219export const CODEX_CANDIDATES = [
220 '/opt/homebrew/bin/codex',
221 '/usr/local/bin/codex',
222 '~/.local/bin/codex',
223 '~/.npm-global/bin/codex',
224 '~/.bun/bin/codex',
225 '~/.volta/bin/codex',
226 '/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
227 '/Applications/Codex.app/Contents/Resources/codex',
228]
229/** Where node is commonly installed, tried after PATH (app-server transport). */
230export const NODE_CANDIDATES = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']
231/** The sidecar, under the plugin root, and the protocol version its `--version` prints. */
232const RUNNER = 'scripts/codex-runner.mjs'
233const RUNNER_PROTOCOL = '1'
234/** The oldest codex-cli this module was verified on (a pre-release of it counts as it). */
235const MIN_CODEX = [0, 163, 0] as const
236/** The `type`s `codex exec --json` prints (codex-cli 0.163); any other counts as drift. */
237const EXEC_TYPES = ['thread.started', 'turn.started', 'turn.completed', 'turn.failed', 'item.started', 'item.updated', 'item.completed', 'error']
238// While a step waits on an app-server run, new messages are looked for this
239// often, as long as the step's budget keeps MID_STEP_RESERVE_MS.
240const MID_STEP_MS = 2000
241const MID_STEP_RESERVE_MS = 4000
242// How long an interrupted app-server run gets to end before it is killed.
243const INTERRUPT_GRACE_MS = 1500
244
245// ---- roles -----------------------------------------------------------------
246
247type Profile = { model: string; effort: string; sandbox: string }
248type ProjectRole = Profile & RolePolicy & { description: string }
249const PROJECT_FILE = '.claude/codex-mod.json'
250
251/** Validates an untrusted project file without I/O; catalog checks happen at registration. */
252export function parseProjectRoles(text: string, { allowFullAccess = false } = {}): { roles: Record<string, ProjectRole>; warnings: string[]; invalid?: true } {
253 const roles: Record<string, ProjectRole> = Object.create(null)
254 const warnings: string[] = []
255 const object = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
256 let parsed: unknown
257 try {
258 parsed = JSON.parse(text)
259 } catch {
260 return { roles, warnings: ['invalid JSON; project roles ignored'], invalid: true }
261 }
262 if (!object(parsed) || !object(parsed.roles)) {
263 return { roles, warnings: ['expected an object with a roles object; project roles ignored'], invalid: true }
264 }
265 const unknown = (o: Record<string, unknown>, keys: string[], where: string) => {
266 for (const k of Object.keys(o)) if (!keys.includes(k)) warnings.push(`${where}: unknown key ${JSON.stringify(k)} ignored`)
267 }
268 unknown(parsed, ['roles'], 'project')
269 const entries = Object.entries(parsed.roles)
270 if (entries.length > 16) warnings.push('at most 16 roles allowed; extra roles skipped')
271 for (const [name, value] of entries.slice(0, 16)) {
272 const bad = (why: string) => warnings.push(`role ${JSON.stringify(name)} skipped: ${why}`)
273 if (!/^[a-z][a-z0-9-]{0,31}$/.test(name) || ['codex', 'codex-child'].includes(name)) {
274 bad('invalid or reserved name')
275 continue
276 }
277 if (!object(value)) {
278 bad('expected an object')
279 continue
280 }
281 unknown(value, ['model', 'effort', 'sandbox', 'description', 'instructions', 'lean', 'capabilities', 'fallbackModels'], `role ${name}`)
282 const { model, effort, description, instructions } = value
283 const sandbox = value.sandbox === undefined ? 'workspace-write' : value.sandbox
284 if (typeof model !== 'string' || model.trim() === '' || typeof effort !== 'string' || effort.trim() === '' || typeof description !== 'string') {
285 bad('model, effort and description must be strings (model and effort nonempty)')
286 continue
287 }
288 if (description.length > 600 || (instructions !== undefined && (typeof instructions !== 'string' || instructions.length > 2000))) {
289 bad('description must be at most 600 chars; instructions must be a string of at most 2000 chars')
290 continue
291 }
292 if (typeof sandbox !== 'string' || !SANDBOXES.includes(sandbox)) {
293 bad('invalid sandbox')
294 continue
295 }
296 if (sandbox === 'danger-full-access' && !allowFullAccess) {
297 bad('danger-full-access requires the user-level allowProjectFullAccess option')
298 continue
299 }
300 if (value.lean !== undefined && typeof value.lean !== 'boolean' || value.capabilities !== undefined && !validCapabilities(value.capabilities) || value.fallbackModels !== undefined && !boundedNames(value.fallbackModels)) {
301 bad('invalid lean, capabilities or fallbackModels schema')
302 continue
303 }
304 roles[name] = { model, effort, sandbox, description, ...(instructions !== undefined ? { instructions } : {}),
305 ...(value.lean !== undefined ? { lean: value.lean as boolean } : {}),
306 ...(value.capabilities !== undefined ? { capabilities: value.capabilities as RolePolicy['capabilities'] } : {}),
307 ...(value.fallbackModels !== undefined ? { fallbackModels: value.fallbackModels as string[] } : {}) }
308 }
309 return { roles, warnings }
310}
311const GENERIC_SUMMARY = 'Delegates the task to OpenAI Codex (codex exec) running locally in the session\'s working directory. Use for a self-contained coding or research task you want done by Codex rather than Claude. The prompt is sent to Codex verbatim.'
312const PLACEHOLDER_PROMPT = 'You are a placeholder. The codex-mod plugin answers this agent\'s turns with OpenAI Codex and no Anthropic request is sent. If you are reading this, the plugin\'s turn.step hook did not run: reply with exactly "codex-mod: hook not active; no Codex run was made." and nothing else. Do not call any tool.'
313
314// ---- options -----------------------------------------------------------------
315
316export type HandbackMode = 'auto' | 'tool' | 'text'
317export type FooterMode = 'full' | 'short' | 'off'
318export type Transport = 'exec' | 'app-server'
319
320export type Config = {
321 /** The userConfig override, if any. */
322 codexOverride?: string
323 /** What is launched: the override, else what session start resolved, else `codex` on PATH. */
324 codexPath?: string
325 lean: boolean
326 globalRules: boolean
327 archiveSessions: boolean
328 approvalMode: ApprovalMode
329 allowedApprovalModes: string[]
330 networkAccess: boolean
331 additionalWritableRoots: string[]
332 allowedCwdRoots: string[]
333 generic: Profile
334 projectRoles: boolean
335 modelAgents: boolean
336 allowProjectFullAccess: boolean
337 allowProjectNonLean: boolean
338 projectCapabilityAllowlist: string[]
339 capacityFallback: string[]
340 extraConfig: string[]
341 intervalMs: number
342 maxActionsPerStep: number
343 handbackMode: HandbackMode
344 footer: FooterMode
345 /** Kill a run after this long; 0: never. */
346 timeoutMs: number
347 /** Launch guard: the most Codex launches per agent in WINDOW_MS. */
348 maxRunsPerWindow: number
349 nativeActions: boolean
350 richRows: boolean
351 nestedAgents: boolean
352 ttlMs: number
353 /** What a run is launched with; `app-server` falls back to exec when its checks fail. */
354 transport: Transport
355 /** The userConfig node override (app-server transport), if any. */
356 nodeOverride?: string
357 /** Option values that were refused (the default was used), for the debug log. */
358 warnings: string[]
359}
360
361/** Validates the userConfig values; a refused value falls back to its default with a warning. */
362export function parseConfig(raw: unknown): Config {
363 const o = (raw ?? {}) as Record<string, unknown>
364 const warnings: string[] = []
365 const bad = (k: string, why: string) => warnings.push(`${k}: ${why}; default used`)
366 const str = (k: string, d: string): string => {
367 const v = o[k]
368 if (v === undefined || v === null || v === '') return d
369 if (typeof v !== 'string') return bad(k, 'not a string'), d
370 return v.trim()
371 }
372 const bool = (k: string, d: boolean): boolean => {
373 const v = o[k]
374 if (v === undefined || v === null) return d
375 if (typeof v !== 'boolean') return bad(k, 'not a boolean'), d
376 return v
377 }
378 const int = (k: string, d: number, min: number, max: number): number => {
379 const v = o[k]
380 if (v === undefined || v === null || v === '') return d
381 const n = typeof v === 'string' ? Number(v) : v
382 if (typeof n !== 'number' || !Number.isFinite(n) || n < min || n > max) return bad(k, `not a number from ${min} to ${max}`), d
383 return Math.round(n)
384 }
385 const oneOf = <T extends string>(k: string, d: T, allowed: readonly T[]): T => {
386 const v = str(k, d)
387 if (!(allowed as readonly string[]).includes(v)) return bad(k, `not one of ${allowed.join(', ')}`), d
388 return v as T
389 }
390 const list = (k: string, d: string[]): string[] => {
391 const v = o[k]
392 if (v === undefined || v === null) return d
393 if (typeof v === 'string') return v.split(/[\n,]/).map(s => s.trim()).filter(Boolean)
394 if (!Array.isArray(v) || v.some(x => typeof x !== 'string')) return bad(k, 'not a list of strings'), d
395 return v.map(s => s.trim()).filter(Boolean)
396 }
397
398 const model = str('defaultModel', '')
399 if (model !== '' && !/^[\w.:-]+$/.test(model)) bad('defaultModel', 'not a model name')
400 const effort = str('defaultEffort', FALLBACK_EFFORT).toLowerCase()
401 if (Object.prototype.hasOwnProperty.call(o, 'roles')) warnings.push('roles: removed in 1.2; declare roles in .claude/codex-mod.json')
402 const extraConfig = list('extraConfig', []).filter(kv => {
403 const m = /^([A-Za-z0-9_.\-"]+)=([\s\S]+)$/.exec(kv)
404 if (!m) return warnings.push(`extraConfig: "${kv}" is not key=value; ignored`), false
405 if (RESERVED_KEYS.includes(m[1]!)) return warnings.push(`extraConfig: "${m[1]}" is set by the plugin per agent; ignored`), false
406 return true
407 })
408 const codexPath = str('codexPath', '')
409 const nodePath = str('nodePath', '')
410 return {
411 codexOverride: codexPath === '' ? undefined : codexPath,
412 codexPath: codexPath === '' ? undefined : codexPath,
413 lean: bool('lean', true),
414 globalRules: bool('globalRules', true),
415 archiveSessions: bool('archiveSessions', false),
416 approvalMode: oneOf<ApprovalMode>('approvalMode', 'never', APPROVAL_MODES),
417 allowedApprovalModes: list('allowedApprovalModes', []).filter(v => (APPROVAL_MODES as readonly string[]).includes(v)),
418 networkAccess: bool('networkAccess', false),
419 additionalWritableRoots: list('additionalWritableRoots', []),
420 allowedCwdRoots: list('allowedCwdRoots', []),
421 generic: {
422 model: model !== '' && /^[\w.:-]+$/.test(model) ? model : '',
423 effort: /^[a-z]+$/.test(effort) ? effort : (bad('defaultEffort', 'not an effort name'), FALLBACK_EFFORT),
424 sandbox: oneOf('defaultSandbox', 'workspace-write', SANDBOXES),
425 },
426 projectRoles: bool('projectRoles', true),
427 modelAgents: bool('modelAgents', true),
428 allowProjectFullAccess: bool('allowProjectFullAccess', false),
429 allowProjectNonLean: bool('allowProjectNonLean', false),
430 projectCapabilityAllowlist: list('projectCapabilityAllowlist', []).filter((s, i) => {
431 const valid = i < 32 && /^(?:webSearch:(?:cached|live)|mcp:[A-Za-z0-9_-]{1,64}|multiAgent|skillsInstructions|apps|plugins|images)$/.test(s)
432 if (!valid) warnings.push(`projectCapabilityAllowlist: invalid grant ${JSON.stringify(s)} ignored`)
433 return valid
434 }),
435 capacityFallback: (() => {
436 const names = list('capacityFallback', ['gpt-6-sol', 'gpt-5.6-sol'])
437 return boundedNames(names) ? names : (bad('capacityFallback', 'expected at most 32 unique model names'), ['gpt-6-sol', 'gpt-5.6-sol'])
438 })(),
439 extraConfig,
440 intervalMs: int('progressIntervalMs', 1000, 0, 60000),
441 maxActionsPerStep: int('maxActionsPerStep', 10, 1, 50),
442 handbackMode: oneOf<HandbackMode>('handbackMode', 'auto', ['auto', 'tool', 'text']),
443 footer: oneOf<FooterMode>('footer', 'full', ['full', 'short', 'off']),
444 timeoutMs: int('timeoutMinutes', 0, 0, 1440) * 60000,
445 maxRunsPerWindow: int('maxRunsPerWindow', 3, 1, 100),
446 nativeActions: bool('nativeActions', true),
447 richRows: bool('richRows', false),
448 nestedAgents: bool('nestedAgents', true),
449 ttlMs: int('pruneDays', 2, 1, 365) * DAY_MS,
450 transport: oneOf<Transport>('transport', 'exec', ['exec', 'app-server']),
451 nodeOverride: nodePath === '' ? undefined : nodePath,
452 warnings,
453 }
454}
455
456/** Kept per agent between steps, runs, worker restarts and resumes. */
457export type AgentRecord = Profile & {
458 agentName: string
459 // Only small row identity/profile metadata belongs in the host store.
460 selection?: { session: string; toolUseId: string; type: string; launchModel: string; launchEffort: string; note?: string }
461 modelRowIds?: string[]
462 thread?: string
463 // Archive state survives option changes so a follow-up can restore this thread.
464 historyArchived?: boolean
465 // Conflicting source IDs permanently remove this record's history authority.
466 historyAmbiguous?: true
467 effectiveConfig?: EffectiveConfig
468 effectiveExecution?: ExecutionPolicy
469 executionFingerprint?: string
470 effectiveFingerprint?: string
471 fallbackModels?: string[]
472 capacityRetryKey?: string
473 lastTaskFingerprint?: Fingerprint
474 launchKeyFingerprint?: Fingerprint
475 capacityRetryKeyFingerprint?: Fingerprint
476 guardTripKeyFingerprint?: Fingerprint
477 recoveryUnavailable?: boolean
478 // A missing total after interruption invalidates the next exec baseline.
479 threadUsage?: { thread: string; total: Usage; valid: boolean }
480 lastTask?: string
481 handedBack?: boolean
482 // true when the host delivers this agent's report through SubagentHandback
483 handback?: boolean
484 // the route the conversation last asked for (auto mode): its latest handback signal
485 handbackLock?: 'tool' | 'text'
486 // the last message handed back (answer + footer), re-delivered on an engine nudge
487 lastAnswer?: string
488 // re-deliveries of lastAnswer since the last launch
489 redeliveries?: number
490 // how many genuine user messages the conversation held at the last launch
491 userCount?: number
492 // launch guard: launch times (ms) in the window, the message of the last
493 // launch, the launches for it, and a trip that blocks that message for good
494 launches?: number[]
495 launchKey?: string
496 launchesSinceNew?: number
497 guardTrip?: { key?: string; reason: string }
498 // user messages folded into the turn while a run was active, run next
499 followUps?: FollowUp[]
500 // Receipt IDs are bounded in the store; bodies recover from private cache files.
501 coordinator?: { received: string[]; pending: { uuid: string; text: string; messageId?: string; sourceUuid?: string; mirrorClaimed?: true }[]; rows?: Record<string, { tool: string; text: string; sourceUuid: string; messageId?: string }> ; outcomes?: Record<string, MessageOutcome>; sources?: Record<string, string>; authoritative?: true; ingress?: { messageId: string; senderToolUseId: string; count: number; fromSend?: true; text?: string }[] }
502 // app-server runs launched so far: the <n> of each run's control dir
503 runSeq?: number
504 // Runtime/cache only: never put lossless source evidence in the host store.
505 wire?: { records: SourceRecord[]; rows: Record<string, SourceRecord | null> }
506 display?: DisplayState
507 mirror?: ChildMirror
508 playback?: { segment?: number; cursor: number; prefix: string; texts: string[]; queue: Queued[]; actions: number; usage?: Usage; usageCanonical?: boolean; usagePartial?: boolean; turnId?: string }
509 updatedAt: number
510}
511
512/**
513 * A user message folded in mid-run: its text and the conversation's genuine
514 * message count when it came. A coordinator delivery the transcript carries as
515 * an attachment, not a user row (see `deliveries`), also has the row's `uuid`
516 * and shares the count of the message it interrupted.
517 */
518export type FollowUp = { text: string; count: number; uuid?: string; messageId?: string; senderToolUseId?: string }
519
520export type FileChange = { path: string; kind: string | { type: string; move_path?: string | null }; move_path?: string | null; diff?: string; status?: string }
521
522/** Public source evidence, independent of the current choice of display row. */
523export type SourceRecord = {
524 transport: Transport
525 event: string
526 threadId?: string
527 turnId?: string
528 itemId?: string
529 cwd?: string
530 executionPolicy?: ExecutionPolicy
531 startedAtMs?: number
532 completedAtMs?: number
533 item?: Record<string, any>
534 data?: Record<string, any>
535 envelope?: Record<string, unknown>
536}
537
538/** Turn/thread snapshots can contain reasoning items too, not only item events. */
539function publicSource(value: any): any {
540 if (Array.isArray(value)) return value.map(publicSource)
541 if (value === null || typeof value !== 'object') return value
542 return Object.fromEntries(Object.entries(value)
543 .filter(([key]) => value.type !== 'reasoning' || key !== 'content')
544 .map(([key, child]) => [key, publicSource(child)]))
545}
546
547const ITEM_TYPES: Record<string, string> = {
548 user_message: 'userMessage', hook_prompt: 'hookPrompt', agent_message: 'agentMessage',
549 function_call_output: 'functionCallOutput', proposed_plan: 'plan', reasoning: 'reasoning',
550 command_execution: 'commandExecution', file_change: 'fileChange', mcp_tool_call: 'mcpToolCall',
551 dynamic_tool_call: 'dynamicToolCall', collab_tool_call: 'collabAgentToolCall',
552 sub_agent_activity: 'subAgentActivity', web_search: 'webSearch', image_view: 'imageView',
553 sleep: 'sleep', image_generation: 'imageGeneration', entered_review_mode: 'enteredReviewMode',
554 exited_review_mode: 'exitedReviewMode', context_compaction: 'contextCompaction',
555}
556const SOURCE_FIELDS = ['aggregatedOutput', 'exitCode', 'commandActions', 'processId', 'durationMs',
557 'senderThreadId', 'receiverThreadIds', 'agentsStates', 'reasoningEffort', 'contentItems',
558 'agentThreadId', 'agentPath', 'revisedPrompt', 'savedPath', 'transparentBackground', 'memoryCitation',
559 'clientId', 'pluginId', 'scriptPath', 'appContext', 'mcpAppUi', 'mcpAppResourceUri', 'readOnlyHint']
560const snake = (key: string) => key.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`)
561
562/** Preserve unknown fields too; only private reasoning is deliberately removed. */
563export function normalizeSource(line: string, context: { transport?: Transport; threadId?: string; turnId?: string; cwd?: string } = {}): SourceRecord | undefined {
564 let ev: any
565 try { ev = publicSource(JSON.parse(line)) } catch { return undefined }
566 if (ev === null || typeof ev !== 'object') return undefined
567 const src = ev.source
568 const raw = src?.item ?? ev.item
569 const item = raw && typeof raw === 'object' ? { ...raw, sourceType: raw.type, type: ITEM_TYPES[raw.type] ?? raw.type } : undefined
570 if (item) {
571 for (const key of SOURCE_FIELDS) if (key in raw || snake(key) in raw) item[key] = key in raw ? raw[key] : raw[snake(key)]
572 if (item.type === 'reasoning') delete item.content
573 if (typeof item.status === 'string') {
574 item.sourceStatus = item.status
575 if (item.status === 'in_progress') item.status = 'inProgress'
576 }
577 if (Array.isArray(item.changes)) item.changes = item.changes.map((c: any) => ({ ...c,
578 kind: typeof c.kind === 'string' ? { type: c.kind, ...(c.move_path !== undefined ? { move_path: c.move_path } : {}) } : c.kind }))
579 }
580 if (!src && !item && typeof ev.type !== 'string') return undefined
581 return {
582 transport: src?.transport ?? context.transport ?? 'exec',
583 event: src?.event ?? ev.type,
584 threadId: src?.threadId ?? ev.thread_id ?? context.threadId,
585 turnId: src?.turnId ?? ev.turn_id ?? context.turnId,
586 itemId: typeof item?.id === 'string' ? item.id : src?.data?.itemId,
587 cwd: item?.cwd ?? context.cwd,
588 ...(typeof src?.startedAtMs === 'number' ? { startedAtMs: src.startedAtMs } : {}),
589 ...(typeof src?.completedAtMs === 'number' ? { completedAtMs: src.completedAtMs } : {}),
590 ...(item ? { item, ...(src?.data ? { data: src.data } : {}) } : { data: src?.data ?? ev }),
591 ...(!src ? { envelope: Object.fromEntries(Object.entries(ev).filter(([key]) => !['item', 'source'].includes(key))) } : {}),
592 }
593}
594
595/** One lifecycle per source identity. Deltas are evidence, never new actions. */
596export class SourceLedger {
597 records: SourceRecord[] = []
598 items = new Map<string, SourceRecord>()
599 private completed = new Set<string>()
600 private started = new Set<string>()
601 private plans = new Map<string, string>()
602 private usageTurns = new Set<string>()
603
604 capture(record: SourceRecord): { record: SourceRecord; render: boolean } {
605 const key = JSON.stringify([record.threadId, record.turnId, record.itemId])
606 if (record.event === 'turn.completed') {
607 const turn = JSON.stringify([record.threadId, record.turnId])
608 if (this.usageTurns.has(turn)) return { record, render: false }
609 this.usageTurns.add(turn)
610 }
611 const end = ['item.completed', 'item/completed'].includes(record.event)
612 const start = ['item.started', 'item/started'].includes(record.event)
613 if (record.itemId && ((end && this.completed.has(key)) || (start && (this.started.has(key) || this.completed.has(key))))) return { record: this.items.get(key) ?? record, render: false }
614 if (record.itemId) {
615 const previous = this.items.get(key)
616 record = { ...previous, ...record, ...(record.item ? { item: { ...previous?.item, ...record.item } } : {}) }
617 if (end && record.item?.type === 'fileChange' && !record.item.changes?.length && previous?.item?.changes?.length) record.item.changes = previous.item.changes
618 if (end && record.item?.type === 'fileChange' && previous?.item?.changes) {
619 record.item.changes = record.item.changes?.map((c: any) => ({ ...previous.item!.changes.find((p: any) => p.path === c.path), ...c }))
620 }
621 if (record.event === 'item/fileChange/patchUpdated') record.item = { ...previous?.item, type: 'fileChange', id: record.itemId, changes: record.data?.changes }
622 this.items.set(key, record)
623 if (end) this.completed.add(key)
624 if (start) this.started.add(key)
625 }
626 this.records.push(record)
627 // Exec todos can repeat the same snapshot at started/updated/completed.
628 if (['todo_list', 'plan_update'].includes(record.item?.type)) {
629 const planKey = JSON.stringify([record.threadId, record.turnId, record.itemId, record.item?.type])
630 const value = stableJSON(record.item)
631 if (this.plans.get(planKey) === value) return { record, render: false }
632 this.plans.set(planKey, value)
633 }
634 return { record, render: true }
635 }
636}
637
638/** What one `codex exec --json` line means to us. */
639export type CodexEvent =
640 | { kind: 'message'; text: string }
641 | { kind: 'thinking' | 'interim'; text: string }
642 | { kind: 'note'; text: string; dim?: true }
643 | { kind: 'action'; action: Action; display?: DisplayTool }
644 | { kind: 'thread'; id: string }
645 | { kind: 'command'; command: string; output: string; exitCode: number | null }
646 | { kind: 'fileStart'; changes: FileChange[] }
647 | { kind: 'files'; changes: FileChange[] }
648 | { kind: 'usage'; input: number; cached: number; output: number }
649 | { kind: 'error'; text: string }
650 | { kind: 'warning'; text: string }
651 | { kind: 'question'; question: Question }
652 // runner-only lines (app-server transport)
653 | { kind: 'turn'; id: string }
654 | { kind: 'steer'; id: string; accepted: boolean; reason?: string; outcome: MessageOutcome }
655 | { kind: 'protocol'; types: string[] }
656 | { kind: 'fatal'; text: string }
657 // a line that is not JSON, or of a type `codex exec --json` does not print
658 | { kind: 'unknown'; type: string }
659 | { kind: 'other' }
660
661type ActionResult = Record<string, unknown> | { type: 'text'; text: string }[]
662
663/** One of Codex's actions as a tool call, and the result it is answered with. */
664export type Action = { tool: string; input: Record<string, unknown>; result: ActionResult; error?: string; source?: SourceRecord; patchPath?: string; mirror?: string }
665
666type NativeName = typeof NATIVE_TOOLS[number]
667type NativeResult = BuiltinToolResults[Exclude<NativeName, 'Glob' | 'Grep'>] | GlobResult | GrepResult
668type DisplayResult = { type: 'text'; text: string }[]
669type GuardAnswer = { result: NativeResult | DisplayResult } | { deny: string }
670const object = (v: unknown): v is Record<string, any> => v !== null && typeof v === 'object' && !Array.isArray(v)
671const strings = (v: unknown): v is string[] => Array.isArray(v) && v.every(s => typeof s === 'string')
672const number = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
673const keysOnly = (v: Record<string, unknown>, keys: string[]) => Object.keys(v).every(key => keys.includes(key))
674const textBlocks = (v: unknown): v is DisplayResult => Array.isArray(v) && v.every(b => object(b) && keysOnly(b, ['type', 'text']) && b.type === 'text' && typeof b.text === 'string')
675const hunks = (v: unknown): boolean => Array.isArray(v) && v.every(h => object(h) &&
676 keysOnly(h, ['oldStart', 'oldLines', 'newStart', 'newLines', 'lines']) &&
677 ['oldStart', 'oldLines', 'newStart', 'newLines'].every(k => number(h[k]) && Number.isInteger(h[k])) && strings(h.lines))
678const todos = (v: unknown): boolean => Array.isArray(v) && v.every(t => object(t) &&
679 keysOnly(t, ['content', 'status', 'activeForm']) && typeof t.content === 'string' && typeof t.activeForm === 'string' && ['pending', 'in_progress', 'completed'].includes(t.status))
680
681/** Canonical, collision-free JSON binding, including nested display arguments. */
682const stableJSON = (value: unknown): string => JSON.stringify(value, (_key, v) =>
683 object(v) ? Object.fromEntries(Object.keys(v).sort().map(key => [key, v[key]])) : v)
684
685/** Validate our deliberately bounded result subset before emission AND consumption. */
686export function validAction(a: Action): a is Action & { result: NativeResult | DisplayResult } {
687 const i = a.input
688 const r = a.result
689 if (!object(i)) return false
690 if (a.tool === 'Read' && validImage(a)) return true
691 if (['Read', 'Glob', 'Grep'].includes(a.tool)) return a.error === undefined && validExploration(a)
692 if (a.tool === 'AskUserQuestion') return a.error === undefined && validAnsweredQuestion(a)
693 if (a.tool === 'ReportFindings') {
694 const expected = findingsAction(i)
695 return expected !== undefined && keysOnly(i, ['findings', 'level']) && stableJSON(r) === stableJSON(expected.result)
696 }
697 let inputOK = false
698 if (a.tool === 'Bash') inputOK = keysOnly(i, ['command', 'description']) && typeof i.command === 'string' && typeof i.description === 'string'
699 else if (a.tool === 'Edit') inputOK = keysOnly(i, ['file_path', 'old_string', 'new_string', 'replace_all']) && ['file_path', 'old_string', 'new_string'].every(k => typeof i[k] === 'string') && (i.replace_all === undefined || i.replace_all === false)
700 else if (a.tool === 'Write') inputOK = keysOnly(i, ['file_path', 'content']) && typeof i.file_path === 'string' && typeof i.content === 'string'
701 else if (a.tool === 'WebSearch') inputOK = keysOnly(i, ['query', 'mode']) && typeof i.query === 'string' && i.query !== '' && ['standard', 'extended'].includes(i.mode)
702 else if (a.tool === 'WebFetch') inputOK = keysOnly(i, ['url', 'prompt']) && typeof i.url === 'string' && i.url !== '' && typeof i.prompt === 'string'
703 else if (a.tool === 'Agent') inputOK = keysOnly(i, ['description', 'prompt', 'subagent_type']) && typeof i.description === 'string' && typeof i.prompt === 'string' && (i.subagent_type === 'codex-mod:codex' || i.subagent_type === 'codex-mod:codex-child' && validMirrorLink(a))
704 else if (a.tool === 'TodoWrite') inputOK = keysOnly(i, ['todos']) && todos(i.todos)
705 else if (a.tool === 'TaskCreate') inputOK = keysOnly(i, ['subject', 'description', 'activeForm', 'metadata']) && typeof i.subject === 'string' && i.subject.startsWith('Codex plan: ') && typeof i.description === 'string' && typeof i.activeForm === 'string' && object(i.metadata) && keysOnly(i.metadata, ['codexPlan', 'sourceThread', 'sourceRevision']) && i.metadata.codexPlan === true && typeof i.metadata.sourceThread === 'string' && Number.isInteger(i.metadata.sourceRevision) && i.metadata.sourceRevision > 0
706 else if (a.tool === 'TaskUpdate') inputOK = keysOnly(i, ['taskId', 'status', 'description']) && typeof i.taskId === 'string' && i.taskId.startsWith('codex-plan-') && (i.status === undefined || ['pending', 'in_progress', 'completed', 'deleted'].includes(i.status)) && (i.description === undefined || typeof i.description === 'string') && Object.keys(i).length > 1
707 else if (a.tool === 'TaskStop') inputOK = keysOnly(i, ['task_id']) && typeof i.task_id === 'string' && i.task_id.startsWith('codex-display-')
708 else if (a.tool === 'SendMessage') inputOK = keysOnly(i, ['to', 'message', 'summary']) && object(i.to) && keysOnly(i.to, ['agentId']) && typeof i.to.agentId === 'string' && i.to.agentId.startsWith('codex-display-') && typeof i.message === 'string' && i.message !== '' && typeof i.summary === 'string'
709 else if (isDisplay(a.tool) || a.tool === 'codex_tool') inputOK = keysOnly(i, ['server', 'name', 'arguments']) && typeof i.server === 'string' && typeof i.name === 'string' && 'arguments' in i
710 // Display names are host-owned; use their registered identity to choose the input schema.
711 if (a.tool === displayTools.codex_plan || a.tool === 'codex_plan') inputOK = keysOnly(i, ['steps', 'explanation']) && (i.explanation === undefined || typeof i.explanation === 'string') && Array.isArray(i.steps) && i.steps.every(s => object(s) && keysOnly(s, ['step', 'status']) && typeof s.step === 'string' && ['pending', 'in_progress', 'completed'].includes(s.status))
712 if (a.tool === displayTools.coordinator_message) inputOK = keysOnly(i, ['sourceUuid', 'text']) && typeof i.sourceUuid === 'string' && typeof i.text === 'string'
713 if (!inputOK) return false
714 if (a.error !== undefined) return typeof a.error === 'string' && a.error !== ''
715 if (!isNative(a.tool)) return textBlocks(r)
716 if (!object(r)) return false
717 switch (a.tool) {
718 case 'Bash': {
719 const d = r.bashEditDiff
720 return keysOnly(r, ['stdout', 'stderr', 'interrupted', 'bashEditDiff']) && typeof r.stdout === 'string' && typeof r.stderr === 'string' && typeof r.interrupted === 'boolean' &&
721 (d === undefined || (object(d) && keysOnly(d, ['files', 'moreFiles', 'changedFiles']) && d.moreFiles === 0 && strings(d.changedFiles) && Array.isArray(d.files) &&
722 d.files.every(f => object(f) && keysOnly(f, ['filePath', 'hunks', 'created', 'deleted']) && typeof f.filePath === 'string' && hunks(f.hunks) &&
723 (f.created === undefined || f.created === true) && (f.deleted === undefined || f.deleted === true) && !(f.created && f.deleted)) &&
724 stableJSON(d.changedFiles) === stableJSON(d.files.map(f => f.filePath))))
725 }
726 case 'Edit': return keysOnly(r, ['filePath', 'oldString', 'newString', 'originalFile', 'structuredPatch', 'userModified', 'replaceAll']) && r.filePath === i.file_path && r.oldString === i.old_string && r.newString === i.new_string && (r.originalFile === null || typeof r.originalFile === 'string') && hunks(r.structuredPatch) && r.userModified === false && r.replaceAll === false
727 case 'Write': return keysOnly(r, ['type', 'filePath', 'content', 'structuredPatch', 'originalFile']) && ['create', 'update'].includes(r.type) && r.filePath === i.file_path && r.content === i.content && hunks(r.structuredPatch) && (r.originalFile === null || typeof r.originalFile === 'string')
728 case 'WebSearch': return keysOnly(r, ['query', 'results', 'durationSeconds']) && r.query === i.query && number(r.durationSeconds) && Array.isArray(r.results) && r.results.every(s => typeof s === 'string' || (object(s) && keysOnly(s, ['tool_use_id', 'content']) && typeof s.tool_use_id === 'string' && s.tool_use_id !== '' && Array.isArray(s.content) && s.content.every(c => object(c) && keysOnly(c, ['title', 'url', 'snippet']) && typeof c.title === 'string' && typeof c.url === 'string' && (c.snippet === undefined || typeof c.snippet === 'string'))))
729 case 'WebFetch': return validFetchResult(r, i.url)
730 case 'TodoWrite': return keysOnly(r, ['oldTodos', 'newTodos']) && todos(r.oldTodos) && todos(r.newTodos) && stableJSON(r.newTodos) === stableJSON(i.todos)
731 case 'TaskCreate': return keysOnly(r, ['task']) && object(r.task) && keysOnly(r.task, ['id', 'subject']) && typeof r.task.id === 'string' && r.task.id.startsWith('codex-plan-') && r.task.subject === i.subject
732 case 'TaskUpdate': return keysOnly(r, ['success', 'taskId', 'updatedFields', 'statusChange']) && r.success === true && r.taskId === i.taskId && stableJSON(r.updatedFields) === stableJSON(Object.keys(i).filter(k => k !== 'taskId')) && (r.statusChange === undefined || (object(r.statusChange) && keysOnly(r.statusChange, ['from', 'to']) && ['pending', 'in_progress', 'completed'].includes(r.statusChange.from) && r.statusChange.to === i.status))
733 case 'TaskStop': return keysOnly(r, ['message', 'task_id', 'task_type']) && r.task_id === i.task_id && r.task_type === 'codex' && typeof r.message === 'string' && r.message !== ''
734 case 'SendMessage': return keysOnly(r, ['status', 'agentsStates']) && r.status === 'completed' && object(r.agentsStates)
735 case 'Agent': {
736 const u = r.usage
737 return keysOnly(r, ['agentId', 'agentType', 'status', 'prompt', 'content', 'canContinueAgent', 'totalToolUseCount', 'totalDurationMs', 'totalTokens', 'usage', 'resolvedModel']) && typeof r.agentId === 'string' && r.agentType === i.subagent_type && r.status === 'completed' && r.prompt === i.prompt && textBlocks(r.content) && r.canContinueAgent === false && ['totalToolUseCount', 'totalDurationMs', 'totalTokens'].every(k => r[k] === 0) && (r.resolvedModel === undefined || typeof r.resolvedModel === 'string') && object(u) && keysOnly(u, ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'server_tool_use', 'service_tier', 'cache_creation']) && u.input_tokens === 0 && u.output_tokens === 0 && ['cache_creation_input_tokens', 'cache_read_input_tokens', 'server_tool_use', 'service_tier', 'cache_creation'].every(k => u[k] === null)
738 }
739 }
740 return false
741}
742
743type Usage = { input: number; cached: number; output: number }
744
745const oneLine = (text: string, max = 160): string => {
746 const t = text.replace(/\s+/g, ' ').trim()
747 return t.length > max ? `${t.slice(0, max - 3)}...` : t
748}
749
750const changesOf = (item: any): FileChange[] =>
751 Array.isArray(item?.changes)
752 ? item.changes.filter((c: any) => typeof c?.path === 'string' && c.path !== '').map((c: any) => ({ ...c }))
753 : []
754
755/**
756 * Maps one JSONL line of `codex exec --json` (verified on codex-cli 0.163), or
757 * a `codexmod.*` line of the app-server runner.
758 */
759export function parseCodexLine(line: string, richRows = false, todoWriteAvailable = false): CodexEvent {
760 let ev: any
761 try {
762 ev = JSON.parse(line)
763 } catch {
764 return { kind: 'unknown', type: 'non-JSON' }
765 }
766 const type = typeof ev?.type === 'string' ? ev.type : undefined
767 if (type?.startsWith('codexmod.')) {
768 if (type === 'codexmod.request' && typeof ev.token === 'string' && typeof ev.method === 'string') return { kind: 'question', question: ev }
769 if (type === 'codexmod.warning' || type === 'codexmod.responseRejected') return { kind: 'warning', text: String(ev.message ?? 'warning') }
770 if (type === 'codexmod.turn' && typeof ev.turn_id === 'string') return { kind: 'turn', id: ev.turn_id }
771 if (type === 'codexmod.steer' && (typeof ev.id === 'string' || typeof ev.id === 'number')) {
772 return { kind: 'steer', id: String(ev.id), accepted: ev.status === 'accepted' || ev.status === 'observed', outcome: ev as unknown as MessageOutcome, ...(typeof ev.reason === 'string' ? { reason: ev.reason } : {}) }
773 }
774 if (type === 'codexmod.protocol') {
775 const names = (v: unknown, tag: string) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').map(x => `${tag} ${x}`) : [])
776 return { kind: 'protocol', types: [...names(ev.unknown_methods, 'method'), ...names(ev.unknown_items, 'item')] }
777 }
778 if (type === 'codexmod.fatal') return { kind: 'fatal', text: String(ev.message ?? 'runner failed') }
779 return { kind: 'other' }
780 }
781 if (type === undefined || !EXEC_TYPES.includes(type)) return { kind: 'unknown', type: type ?? 'untyped' }
782 const item = ev?.item
783 if (ev?.type === 'thread.started' && typeof ev.thread_id === 'string') return { kind: 'thread', id: ev.thread_id }
784 if (ev?.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string') {
785 return { kind: ['commentary', 'partial_answer'].includes(item.phase) ? 'interim' : 'message', text: item.text }
786 }
787 if (ev?.type === 'item.completed' && item?.type === 'command_execution') {
788 return {
789 kind: 'command',
790 command: String(item.command ?? ''),
791 output: typeof item.aggregated_output === 'string' ? item.aggregated_output : '',
792 exitCode: typeof item.exit_code === 'number' ? item.exit_code : null,
793 }
794 }
795 if (ev?.type === 'item.started' && item?.type === 'file_change') return { kind: 'fileStart', changes: changesOf(item) }
796 if (ev?.type === 'item.completed' && item?.type === 'file_change') return { kind: 'files', changes: changesOf(item) }
797 if (ev.type === 'item.started' || ev.type === 'item.completed' || ev.type === 'item.updated') {
798 if (item?.type === 'todo_list' || item?.type === 'plan_update') {
799 const steps: { step: string; status: 'pending' | 'in_progress' | 'completed' }[] = item.type === 'todo_list'
800 ? (Array.isArray(item.items) ? item.items : []).map((s: any) => ({ step: String(s?.text ?? ''), status: s?.completed ? 'completed' : 'pending' }))
801 : (Array.isArray(item.steps) ? item.steps : []).map((s: any) => ({ step: String(s?.step ?? ''), status: s?.status === 'inProgress' ? 'in_progress' : ['pending', 'in_progress', 'completed'].includes(s?.status) ? s.status : 'pending' }))
802 const input = { ...(typeof item.explanation === 'string' ? { explanation: item.explanation } : {}), steps }
803 if (richRows && todoWriteAvailable) {
804 // BuiltinToolInputs['TodoWrite'] requires activeForm on every entry.
805 const todos = steps.map(({ step, status }) => ({ content: step, status, activeForm: step }))
806 // These are display snapshots of Codex's plan, never the host's todo state.
807 const result: BuiltinToolResults['TodoWrite'] = { oldTodos: [], newTodos: todos }
808 return { kind: 'action', action: { tool: 'TodoWrite', input: { todos }, result } }
809 }
810 return { kind: 'action', display: 'codex_plan', action: { tool: 'codex_plan', input, result: [{ type: 'text', text: planText(input) }] } }
811 }
812 }
813 // Only completed items have reliable queries and tool results. JSON.parse
814 // keeps exec's last repeated id key; the source ledger deduplicates that id.
815 if (ev.type === 'item.completed') {
816 if (item?.type === 'reasoning') {
817 const text = Array.isArray(item.summary) ? item.summary.filter((s: unknown) => typeof s === 'string').join('\n\n') : typeof item.text === 'string' ? item.text : ''
818 return text.trim() ? { kind: 'thinking', text } : { kind: 'other' }
819 }
820 if (item?.type === 'web_search') {
821 const action = webAction(ev.source?.item ?? item)
822 return { kind: 'action', action, ...(action.tool === 'codex_tool' ? { display: 'codex_tool' as const } : {}) }
823 }
824 if (item?.type === 'mcp_tool_call' || item?.type === 'dynamic_tool_call') {
825 return { kind: 'action', display: 'codex_tool', action: toolFallback(ev.source?.item ?? item, 'Host input schema and verified server binding unavailable; replacement contract unknown.') }
826 }
827 if (item?.type === 'proposed_plan' && typeof item.text === 'string') return { kind: 'interim', text: `Proposed plan:\n${item.text}` }
828 if (item?.type === 'error') return { kind: 'warning', text: String(item.message ?? 'Codex warning') }
829 if (item?.type === 'note' && typeof item.text === 'string') return { kind: 'note', text: `↪ ${oneLine(item.text, 300)}`, ...(item.dim === true ? { dim: true as const } : {}) }
830 if (item?.type === 'collab_tool_call' || item?.type === 'collabAgentToolCall') {
831 return childRows(item, emptyDisplayState(), { session: 'unreported', agent: 'unreported', thread: 'unreported', rich: richRows, tools: new Set(richRows ? ['Agent'] : []) })[0] ?? { kind: 'other' }
832 }
833 }
834 if (ev?.type === 'turn.completed' && ev.usage) {
835 return {
836 kind: 'usage',
837 input: Number(ev.usage.input_tokens ?? 0),
838 cached: Number(ev.usage.cached_input_tokens ?? 0),
839 output: Number(ev.usage.output_tokens ?? 0),
840 }
841 }
842 if (ev?.type === 'turn.failed') return { kind: 'error', text: String(ev.error?.message ?? 'turn failed') }
843 if (ev?.type === 'error') return { kind: 'error', text: String(ev.message ?? 'error') }
844 if (item && ![...Object.keys(ITEM_TYPES), ...Object.values(ITEM_TYPES), 'note', 'todo_list', 'plan_update', 'source_only', 'error'].includes(item.type)) return { kind: 'unknown', type: `item ${String(item.type)}` }
845 return { kind: 'other' }
846}
847
848/** A coordinator's mid-run message without the engine's framing; other text unchanged. */
849export function stripCoordinator(text: string): string {
850 const m = COORDINATOR.exec(text)
851 return m ? m[1]!.trim() : text
852}
853
854/**
855 * The coordinator's message when `text` holds the engine's delivery of one
856 * sent mid-run as a reminder block (an attachment's rendering, alone or among
857 * other blocks); undefined for anything else, a peer session's or a plugin's
858 * "sent a message while you were working" included. A bare delivery (no
859 * wrapper) is `cleanText`'s to unwrap.
860 */
861export function coordinatorPayload(text: string, verbatim = false): string | undefined {
862 // Match the host's closing tag (including its id), beyond any tags in the body.
863 const raw = /<system-reminder(\s[^>]*)?>\nThe coordinator sent a message while you were working:[ \t]*\n([\s\S]*)<\/system-reminder\1>/.exec(text)?.[2]
864 if (raw !== undefined) {
865 const suffix = '\n\nAddress this before completing your current task.\n'
866 const body = raw.endsWith(suffix) ? raw.slice(0, -suffix.length) : raw.endsWith('\n') ? raw.slice(0, -1) : raw
867 return verbatim ? body : body.trim()
868 }
869 for (const block of text.match(REMINDER) ?? []) {
870 const inner = REMINDER_WRAP.exec(block.trim())?.[1] ?? ''
871 const m = COORDINATOR.exec(inner)
872 if (m) return m[1]!.trim()
873 }
874 return undefined
875}
876
877/** Drops engine notice lines (`[handback-send-enforce] ...`, `[Your previous response ...]`) from both edges. */
878function stripNotices(text: string): string {
879 const lines = text.split('\n')
880 while (lines.length > 0 && (lines[0]!.trim() === '' || NOTICE_LINE.test(lines[0]!))) lines.shift()
881 while (lines.length > 0 && (lines.at(-1)!.trim() === '' || NOTICE_LINE.test(lines.at(-1)!))) lines.pop()
882 return lines.join('\n')
883}
884
885/**
886 * A user row's text as Codex should read it: reminders, engine notices and the
887 * coordinator framing removed; '' for a row that is only engine prose (a
888 * system reminder, a nudge such as `[handback-send-enforce]`), never a task.
889 */
890export const cleanText = (text: string): string => stripCoordinator(stripNotices(text.replace(REMINDER, '')).trim())
891
892/** One block of the agent's conversation, flattened in order. */
893export type ConvItem =
894 | { role: 'user'; kind: 'text'; text: string }
895 | { role: 'user'; kind: 'result'; id: string; text: string; isError: boolean }
896 | { role: 'assistant'; kind: 'text'; text: string }
897 | { role: 'assistant'; kind: 'use'; id: string; tool: string }
898
899const blockText = (c: unknown): string =>
900 typeof c === 'string' ? c : Array.isArray(c) ? c.map(b => (typeof b === 'string' ? b : b?.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('') : ''
901
902/**
903 * The Messages API form (`$.session.messages({ agentId, as: "api" })`), which
904 * holds every row a request carries, the meta rows included (the host's
905 * SubagentHandback reminder, engine nudges, a coordinator's delivery).
906 */
907export function itemsFromApi(messages: readonly any[]): ConvItem[] {
908 const items: ConvItem[] = []
909 for (const m of messages) {
910 const role = m?.role === 'assistant' ? 'assistant' : m?.role === 'user' ? 'user' : undefined
911 if (role === undefined) continue
912 const blocks: any[] = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : Array.isArray(m.content) ? m.content : []
913 for (const b of blocks) {
914 if (b?.type === 'text' && typeof b.text === 'string') items.push({ role, kind: 'text', text: b.text } as ConvItem)
915 else if (role === 'assistant' && b?.type === 'tool_use') items.push({ role, kind: 'use', id: String(b.id ?? ''), tool: String(b.name ?? '') })
916 else if (role === 'user' && b?.type === 'tool_result') items.push({ role, kind: 'result', id: String(b.tool_use_id ?? ''), text: blockText(b.content), isError: b.is_error === true })
917 }
918 }
919 return items
920}
921
922/** The rows form (SessionMessage). The engine leaves meta rows OUT of it, so it is only the fallback. */
923export function itemsFromRows(rows: readonly SessionMessage[]): ConvItem[] {
924 const items: ConvItem[] = []
925 for (const r of rows) {
926 if (r.role === 'assistant') {
927 if (r.text !== '') items.push({ role: 'assistant', kind: 'text', text: r.text })
928 for (const u of r.toolUses ?? []) items.push({ role: 'assistant', kind: 'use', id: u.tool_use_id, tool: u.tool })
929 } else {
930 for (const t of r.toolResults ?? []) items.push({ role: 'user', kind: 'result', id: t.tool_use_id, text: String(t.text ?? ''), isError: t.isError === true })
931 if (r.text !== '') items.push({ role: 'user', kind: 'text', text: r.text })
932 }
933 }
934 return items
935}
936
937/** What a step needs to know of the agent's conversation. */
938export type Reading = {
939 /** The latest handback signal: a reminder/mention/enforce nudge (tool) or a countermand/not-active result (text). */
940 signal?: 'tool' | 'text'
941 /** Any failed/not-active handback result, even if later blocks supersede the signal. */
942 handbackFailed: boolean
943 /** An engine `[handback-send-enforce]` nudge is in the conversation. */
944 enforce: boolean
945 /** The newest genuine user message (cleaned) and how many genuine user messages there are. */
946 latest?: { text: string; count: number; coordinator?: true }
947 /** Tool results after the last assistant block, each with the tool it answers when known. */
948 results: { id: string; tool?: string; text: string; isError: boolean }[]
949 /** A genuine user message follows those results. */
950 textAfterResults: boolean
951 /** After the last assistant block, a user row that is only engine prose (a nudge, a reminder). */
952 nudged: boolean
953}
954
955/** Reads the conversation: handback signals, the newest genuine user message, the trailing tool results. */
956export function readConversation(items: readonly ConvItem[]): Reading {
957 const toolOf = new Map<string, string>()
958 let signal: Reading['signal']
959 let handbackFailed = false
960 let enforce = false
961 let latest: Reading['latest']
962 let count = 0
963 let results: Reading['results'] = []
964 let textAfterResults = false
965 let nudged = false
966 let sawAssistant = false
967 for (const it of items) {
968 if (it.role === 'assistant') {
969 results = []
970 textAfterResults = false
971 nudged = false
972 sawAssistant = true
973 if (it.kind === 'use') toolOf.set(it.id, it.tool)
974 else if (HANDBACK_MENTION.test(it.text)) signal = 'tool'
975 continue
976 }
977 if (it.kind === 'result') {
978 const tool = toolOf.get(it.id)
979 results.push({ id: it.id, tool, text: it.text, isError: it.isError })
980 if (tool === HANDBACK_TOOL && (it.isError || NOT_ACTIVE.test(it.text))) {
981 signal = 'text'
982 handbackFailed = true
983 }
984 continue
985 }
986 if (COUNTERMAND.test(it.text)) signal = 'text'
987 else if (ENFORCE.test(it.text)) {
988 signal = 'tool'
989 enforce = true
990 }
991 else if (HANDBACK_MENTION.test(it.text)) signal = 'tool'
992 // a coordinator's delivery rendered as a reminder block is a message, not engine prose
993 const payload = coordinatorPayload(it.text)
994 const text = payload ?? cleanText(it.text)
995 if (text === '') {
996 if (sawAssistant) nudged = true
997 continue
998 }
999 count++
1000 latest = { text, count, ...(payload !== undefined ? { coordinator: true as const } : {}) }
1001 if (results.length > 0) textAfterResults = true
1002 }
1003 return { signal, handbackFailed, enforce, latest, results, textAfterResults, nudged }
1004}
1005
1006type ConversationSource = 'api' | 'rows' | 'denied'
1007
1008/** A missing signal is evidence for text only in the API form, which preserves meta reminders. */
1009export function chooseHandback(mode: HandbackMode, source: ConversationSource, signal?: Reading['signal'], lock?: Reading['signal'], sessionInactive = false): boolean {
1010 if (mode === 'tool') return true
1011 if (mode === 'text') return false
1012 return (signal ?? lock ?? (sessionInactive || source === 'api' ? 'text' : 'tool')) === 'tool'
1013}
1014
1015/** True for a compaction summary / continuation notice (no flag on the row type). */
1016export function isCompactionText(text: string): boolean {
1017 return COMPACTION.test(text)
1018}
1019
1020/** Leading `codex-model:` / `codex-effort:` lines, and the task without them. */
1021export function parseOverrides(task: string): { model?: string; effort?: string; rest: string } {
1022 const out: { model?: string; effort?: string; rest: string } = { rest: task }
1023 for (let m = OVERRIDE.exec(out.rest); m; m = OVERRIDE.exec(out.rest)) {
1024 if (m[1]!.toLowerCase() === 'model') out.model = m[2]!
1025 else out.effort = m[2]!.toLowerCase()
1026 out.rest = out.rest.slice(m[0].length)
1027 }
1028 return out
1029}
1030
1031/** 950 -> "950", 37412 -> "37.4K", 154000 -> "154K", 1250000 -> "1.3M". */
1032export function compact(n: number): string {
1033 if (n < 1000) return String(n)
1034 if (n < 100000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`
1035 if (n < 1000000) return `${Math.round(n / 1000)}K`
1036 return `${(n / 1000000).toFixed(1).replace(/\.0$/, '')}M`
1037}
1038
1039/**
1040 * The footer line retains the full thread ID; token counts are rounded for display.
1041 * an app-server run adds `app-server` (and `N steers`) after the sandbox, a fallback run why it is exec.
1042 */
1043export function footer(p: Profile & { agent: string; actions: number; usage?: Usage; thread?: string; transport?: string; steers?: number; patches?: number; patchRows?: number; usagePartial?: boolean; treeUsage?: TreeSnapshot; configNote?: string }, mode: FooterMode = 'full'): string {
1044 if (mode === 'off') return ''
1045 if (mode === 'short') return [p.agent, p.model, p.effort, p.configNote].filter(Boolean).join(' · ')
1046 const parts = [p.agent, p.model, p.effort, p.sandbox]
1047 if (p.transport !== undefined) parts.push(p.transport)
1048 if (p.steers !== undefined && p.steers > 0) parts.push(`${p.steers} steer${p.steers === 1 ? '' : 's'}`)
1049 parts.push(`${p.actions} action${p.actions === 1 ? '' : 's'}`)
1050 if (p.patches) parts.push(`${p.patches} patch${p.patches === 1 ? '' : 'es'} / ${p.patchRows} file row${p.patchRows === 1 ? '' : 's'}`)
1051 if (p.usagePartial) parts.push('direct usage partial/unknown')
1052 else if (p.usage !== undefined) {
1053 const fresh = Math.max(0, p.usage.input - p.usage.cached)
1054 parts.push(`${compact(fresh)} new tokens (${compact(p.usage.cached)} cached, ${compact(p.usage.output)} out)`)
1055 }
1056 if (p.treeUsage) {
1057 const t = p.treeUsage
1058 const exact = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
1059 parts.push(`tree ${exact(t.tree.input + t.tree.output)} tokens (direct ${exact(t.direct.input + t.direct.output)}; children ${exact(t.tree.input + t.tree.output - t.direct.input - t.direct.output)}) · new ${compact(t.tree.input - t.tree.cached)} / cached ${compact(t.tree.cached)} / out ${compact(t.tree.output)}${t.active || t.missing ? ` (partial: ${t.active} active, ${t.missing} missing)` : ''}`)
1060 }
1061 if (p.thread !== undefined) parts.push(`thread ${p.thread}`)
1062 if (p.configNote) parts.push(p.configNote)
1063 return parts.join(' · ')
1064}
1065
1066// ---- model catalog -----------------------------------------------------------
1067
1068export type ModelInfo = { slug: string; efforts: string[]; defaultEffort?: string }
1069
1070/** The models Codex lists (visibility `list`), by priority, from models_cache.json; undefined when unreadable. */
1071export function parseModelsCache(json: string): ModelInfo[] | undefined {
1072 let d: any
1073 try {
1074 d = JSON.parse(json)
1075 } catch {
1076 return undefined
1077 }
1078 if (!Array.isArray(d?.models)) return undefined
1079 return d.models
1080 .filter((m: any) => typeof m?.slug === 'string' && m.slug !== '' && m.visibility === 'list')
1081 .map((m: any, i: number) => ({ m, i, p: typeof m.priority === 'number' ? m.priority : Number.POSITIVE_INFINITY }))
1082 .sort((a: any, b: any) => a.p - b.p || a.i - b.i)
1083 .map(({ m }: any) => ({
1084 slug: m.slug,
1085 efforts: Array.isArray(m.supported_reasoning_levels)
1086 ? m.supported_reasoning_levels.map((l: any) => (typeof l === 'string' ? l : l?.effort)).filter((x: unknown): x is string => typeof x === 'string')
1087 : [],
1088 defaultEffort: typeof m.default_reasoning_level === 'string' ? m.default_reasoning_level : undefined,
1089 }))
1090}
1091
1092/** Why `model`/`effort` cannot run, given the catalog; undefined when they can (or no catalog is known). */
1093export function checkProfile(catalog: readonly ModelInfo[] | undefined, model: string, effort: string): string | undefined {
1094 if (catalog === undefined || catalog.length === 0) return undefined
1095 const info = catalog.find(m => m.slug === model)
1096 if (info === undefined) return `model ${model} not available (available: ${catalog.map(m => m.slug).join(', ')})`
1097 if (info.efforts.length > 0 && !info.efforts.includes(effort)) {
1098 return `effort ${effort} not supported by ${model} (supported: ${info.efforts.join(', ')})`
1099 }
1100 return undefined
1101}
1102
1103/** `gpt-a (low|medium), gpt-b (low)`: the catalog as agent descriptions list it. */
1104export function catalogLine(catalog: readonly ModelInfo[]): string {
1105 return catalog.map(m => (m.efforts.length > 0 ? `${m.slug} (${m.efforts.join('|')})` : m.slug)).join(', ')
1106}
1107
1108/** One resolver for spawn presentation and execution. Explicit prompt errors
1109 * still fail the run; an unsupported native Agent effort uses the default. */
1110export function resolveProfile(base: Profile, task: string, list?: readonly ModelInfo[], nativeEffort?: unknown): { profile: Profile; rest: string; note?: string; invalid?: string } {
1111 const o = parseOverrides(executionRequest(task).rest)
1112 const model = o.model ?? (base.model || list?.[0]?.slug || FALLBACK_MODEL)
1113 const info = list?.find(m => m.slug === model)
1114 const defaultEffort = base.effort || info?.defaultEffort || FALLBACK_EFFORT
1115 let effort = o.effort ?? defaultEffort
1116 let note: string | undefined
1117 if (o.effort === undefined && nativeEffort !== undefined) {
1118 const supported = info?.efforts.length ? info.efforts : ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
1119 if (typeof nativeEffort === 'string' && supported.includes(nativeEffort)) effort = nativeEffort
1120 else {
1121 effort = supported.includes(defaultEffort) ? defaultEffort
1122 : info?.defaultEffort && supported.includes(info.defaultEffort) ? info.defaultEffort : supported[0]!
1123 const supplied = typeof nativeEffort === 'string' ? oneLine(nativeEffort, 80) : typeof nativeEffort
1124 note = `Agent effort ${JSON.stringify(supplied)} is not supported by ${model}; using default ${effort}`
1125 }
1126 }
1127 return { profile: { model, effort, sandbox: base.sandbox }, rest: o.rest, note, invalid: checkProfile(list, model, effort) }
1128}
1129
1130export const modelTitle = (profile: Pick<Profile, 'model' | 'effort'>, description: string): string =>
1131 `Codex ${profile.model} · ${profile.effort} · ${description}`
1132
1133// ---- actions ---------------------------------------------------------------
1134
1135/** Unquotes one shell word (`'a'\''b'`, `"x \"y\""`, `pwd`); undefined if it is not exactly one word. */
1136function unquoteWord(s: string): string | undefined {
1137 let out = ''
1138 let i = 0
1139 while (i < s.length) {
1140 const c = s[i]!
1141 if (c === "'") {
1142 const j = s.indexOf("'", i + 1)
1143 if (j < 0) return undefined
1144 out += s.slice(i + 1, j)
1145 i = j + 1
1146 } else if (c === '"') {
1147 let j = i + 1
1148 for (;;) {
1149 if (j >= s.length) return undefined
1150 const d = s[j]!
1151 if (d === '\\' && j + 1 < s.length && '"\\$`\n'.includes(s[j + 1]!)) {
1152 out += s[j + 1]
1153 j += 2
1154 continue
1155 }
1156 if (d === '"') break
1157 out += d
1158 j++
1159 }
1160 i = j + 1
1161 } else if (c === '\\' && i + 1 < s.length) {
1162 out += s[i + 1]
1163 i += 2
1164 } else if (/\s/.test(c)) {
1165 return undefined
1166 } else {
1167 out += c
1168 i++
1169 }
1170 }
1171 return out
1172}
1173
1174/** `/bin/zsh -lc 'mkdir x'` -> `mkdir x`; anything else unchanged. */
1175export function unwrapShell(command: string): string {
1176 const m = /^(?:\/usr)?(?:\/bin\/)?(?:ba|z|da)?sh\s+-l?c\s+([\s\S]+)$/.exec(command.trim())
1177 if (!m) return command
1178 return unquoteWord(m[1]!.trim()) ?? command
1179}
1180
1181const DESCRIBE: Record<string, string> = {
1182 ls: 'List files', cat: 'Show file contents', head: 'Read the start of a file', tail: 'Read the end of a file',
1183 nl: 'Show file with line numbers', mkdir: 'Create directory', rm: 'Remove files', cp: 'Copy files', mv: 'Move files',
1184 touch: 'Create file', rg: 'Search files', grep: 'Search files', find: 'Find files', pwd: 'Show current directory',
1185 echo: 'Print text', printf: 'Print text', wc: 'Count lines', diff: 'Compare files', chmod: 'Change file mode',
1186 python: 'Run Python', python3: 'Run Python', node: 'Run Node.js', test: 'Check a condition', which: 'Locate a program',
1187}
1188
1189// Labels alone use this bounded scanner. It never evaluates shell syntax or
1190// supplies evidence for the separate, stricter native exploration classifier.
1191function shellLabelParts(command: string): string[] | undefined {
1192 if (command.length > 8192) return undefined
1193 const parts: string[] = []
1194 let quote = '', start = 0
1195 for (let i = 0; i < command.length; i++) {
1196 const c = command[i]!
1197 if (quote === "'") { if (c === "'") quote = ''; continue }
1198 if (c === '\\') { if (i + 1 >= command.length || command[i + 1] === '\n') return undefined; i++; continue }
1199 if (quote === '"') {
1200 if (c === '"') quote = ''hooks/mirror-launch.ts 122 lines1// Host events can omit the loop ID and cwd. Authority comes from the step
2// currently awaiting its own API spawn, never from an event's missing fields.
3export type MirrorLaunch = {
4 token: string; parentHost: string; session: string; depth: number
5 inputDigest: string; launch: string; hostToolId?: string; host?: string
6}
7export type MirrorAuthority = {
8 parentHost: string; session: string; toolUseId?: string; spawned?: boolean
9 retryUsed?: boolean; retiredToolUseId?: string
10}
11export type MirrorVerdict = 'admit' | 'no-reservation' | 'no-authority' | 'origin' | 'state' | 'depth' | 'session' |
12 'type' | 'token' | 'digest' | 'parent' | 'background' | 'fork' | 'resume' | 'workflow' | 'teammate' |
13 'reused' | 'provider' | 'tool' | 'keys' | 'tool-id' | 'host-id' | 'no-host'
14type Phase = 'call' | 'check' | 'spawn'
15const object = (v: any) => v !== null && typeof v === 'object' && !Array.isArray(v)
16
17export const mirrorLaunchDigest = (input: any): string => JSON.stringify({
18 subagentType: input.subagentType, prompt: input.prompt, description: input.description, name: input.name,
19})
20
21// This host denial guarantees that no agent started and explicitly requests
22// one unchanged retry. Other denials and uncertain outcomes grant no retry.
23export const MIRROR_AUTO_MODE_NO_VERDICT = 'The server-side auto mode classifier gave no verdict for Agent: the request that produced this action did not ask for one. Issue the action again once, as-is; if it is denied again, continue with other tasks that don’t require it and tell the user that auto mode could not evaluate it. Note: reading files, searching code, and other read-only operations do not require the classifier and can still be used.'
24const MIRROR_AUTO_MODE_NO_VERDICT_ASCII = 'The server-side auto mode classifier gave no verdict for Agent: the request that produced this action did not ask for one. Issue the action again once, as-is; if it is denied again, continue with other tasks that don\'t require it and tell the user that auto mode could not evaluate it. Note: reading files, searching code, and other read-only operations do not require the classifier and can still be used.'
25export const isMirrorAutoModeNoVerdict = (reason: unknown): boolean =>
26 reason === MIRROR_AUTO_MODE_NO_VERDICT || reason === MIRROR_AUTO_MODE_NO_VERDICT_ASCII
27
28export function prepareMirrorRetry(m: MirrorLaunch, authority: MirrorAuthority, result: { deny?: string; agentId?: string }, input: any, parentHost: string, session: string): boolean {
29 if (!isMirrorAutoModeNoVerdict(result.deny) || result.agentId !== undefined || m.host !== undefined || m.launch !== 'launching' || ![2, 3].includes(m.depth) ||
30 authority.spawned !== false || authority.retryUsed || authority.parentHost !== parentHost || m.parentHost !== parentHost ||
31 authority.session !== session || m.session !== session || input.prompt !== m.token || mirrorLaunchDigest(input) !== m.inputDigest ||
32 !authority.toolUseId || authority.toolUseId !== m.hostToolId) return false
33 authority.retryUsed = true
34 authority.retiredToolUseId = authority.toolUseId
35 delete authority.toolUseId
36 delete m.hostToolId
37 return true
38}
39
40/** Consume one source-step capability, allowing its call/check/spawn phases
41 * to share exactly one core tool ID. A second spawn cannot reuse the token. */
42export function admitMirrorLaunch(m: MirrorLaunch | undefined, authority: MirrorAuthority | undefined, e: any, origin: string, phase: Phase): MirrorVerdict {
43 if (!m) return 'no-reservation'
44 if (!authority) return 'no-authority'
45 if (origin !== 'codex-mod') return 'origin'
46 if (m.launch !== 'launching') return 'state'
47 if (![2, 3].includes(m.depth)) return 'depth'
48 if (authority.session !== m.session) return 'session'
49 if (!m.parentHost || authority.parentHost !== m.parentHost) return 'parent'
50 const nested = phase === 'check' && object(e.input)
51 const input = nested ? e.input : e
52 const type = phase === 'spawn' ? input.subagentType : input.subagent_type
53 if (type !== 'codex-mod:codex-child') return 'type'
54 if (input.prompt !== m.token) return 'token'
55 if (typeof input.name !== 'string' || typeof input.description !== 'string' ||
56 mirrorLaunchDigest({ subagentType: type, prompt: input.prompt, description: input.description, name: input.name }) !== m.inputDigest) return 'digest'
57 const parent = phase === 'spawn' ? e.parentAgentId : e.agentId
58 if (parent !== undefined && parent !== authority.parentHost) return 'parent'
59 if (phase === 'spawn') {
60 if (e.background !== true) return 'background'
61 if (e.fork !== false) return 'fork'
62 if (e.resume) return 'resume'
63 if (e.workflow) return 'workflow'
64 if (e.isTeammate) return 'teammate'
65 if (authority.spawned) return 'reused'
66 if (e.provider?.plugin !== undefined && !ours(e.provider.plugin)) return 'provider'
67 } else {
68 if (e.tool !== 'Agent') return 'tool'
69 if (input.run_in_background === false) return 'background'
70 const keys = nested ? Object.keys(e.input) : Object.keys(e).filter(k => !['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent', ...(phase === 'check' ? ['ceiling'] : [])].includes(k))
71 if (keys.some(k => !['subagent_type', 'prompt', 'description', 'name', 'cwd', 'run_in_background'].includes(k))) return 'keys'
72 if (nested && Object.keys(e).some(k => !['tool', 'tool_use_id', 'agentId', 'input', 'ceiling'].includes(k))) return 'keys'
73 }
74 const id = e.tool_use_id
75 if (typeof id !== 'string' || id === '' || id === authority.retiredToolUseId || id.startsWith('toolu_codexmod_') || authority.toolUseId !== undefined && authority.toolUseId !== id ||
76 m.hostToolId !== undefined && m.hostToolId !== id) return 'tool-id'
77 authority.toolUseId = id
78 m.hostToolId = id
79 if (phase === 'spawn') authority.spawned = true
80 return 'admit'
81}
82
83/** Explicit host metadata must agree. Omitted metadata needs the admitted
84 * core tool ID; a first turn can precede both the spawn ack and list entry. */
85export function mirrorHostMatches(m: MirrorLaunch, host: string, info: any, admitted = false): MirrorVerdict {
86 if (typeof host !== 'string' || !host || m.host !== undefined && m.host !== host) return 'host-id'
87 if (host === m.parentHost) return 'parent'
88 if (!info && !admitted) return 'no-host'
89 if (info) {
90 if (info.id !== host) return 'host-id'
91 if (info.type !== 'codex-mod:codex-child') return 'type'
92 if (info.parentId !== undefined && info.parentId !== m.parentHost) return 'parent'
93 if (info.spawnedBy !== undefined && !ours(info.spawnedBy)) return 'provider'
94 }
95 return info?.parentId !== undefined || typeof m.hostToolId === 'string' && m.hostToolId !== '' ? 'admit' : 'tool-id'
96}
97
98// Only field names, boolean flags and identity tags enter diagnostics. Never
99// log prompt tokens, descriptions, names, cwd, model text or raw event values.
100const tag = (v: unknown) => v === undefined ? 'absent' : typeof v === 'string' && /^[\w:.@-]{1,100}$/.test(v) ? v : 'other'
101// The host names a plugin either bare or with its marketplace (`codex-mod@codex-mod`).
102const ours = (v: unknown) => v === 'codex-mod' || typeof v === 'string' && v.startsWith('codex-mod@')
103const flag = (v: unknown) => v === true ? true : v === false ? false : v === undefined ? 'absent' : 'other'
104export function mirrorLaunchDiagnostic(phase: string, verdict: MirrorVerdict, e: any, origin: string, m?: MirrorLaunch): string {
105 const summary = { keys: Object.keys(e).sort(), inputKeys: object(e.input) ? Object.keys(e.input).sort() : undefined,
106 origin: tag(origin), provider: { plugin: tag(e.provider?.plugin), tier: tag(e.provider?.tier) },
107 background: flag(e.background ?? e.input?.run_in_background ?? e.run_in_background), fork: flag(e.fork), depth: m?.depth ?? 'absent' }
108 return verdict === 'admit'
109 ? `codex-mod: mirror launch admitted at ${phase}: tool_use_id=${tag(e.tool_use_id)} (${JSON.stringify(summary)})`
110 : `codex-mod: mirror launch refused at ${phase}: ${verdict} (${JSON.stringify(summary)})`
111}
112
113/** Re-entry catches cannot use engine APIs. The live parent drains their
114 * diagnostics after spawn settles; ordinary dispatches also drain leftovers. */
115export class MirrorDiagnostics {
116 private lines: string[] = []
117 add(line: string): void { this.lines.push(line) }
118 flush(log: (line: string) => void): void {
119 for (const line of this.lines.splice(0)) log(line)
120 }
121}
122hooks/exploration.ts 295 lines1import type { Action, SourceRecord } from './codex.ts'
2
3// Glob/Grep are runtime tools absent from BuiltinToolResults in this host.
4// Contracts: research/r1/CLAUDE-TOOLS.md, runtime schemas at 2014 and 2034.
5export type GlobResult = { durationMs: number; numFiles: number; filenames: string[]; truncated: boolean }
6export type GrepResult = { mode: 'content' | 'files_with_matches' | 'count'; numFiles: number; filenames: string[]; content?: string; numLines?: number; numMatches?: number }
7
8const integer = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0
9const lines = (s: string) => s === '' ? 0 : s.split('\n').length - (s.endsWith('\n') ? 1 : 0)
10const keys = (v: Record<string, unknown>, allowed: string[]) => Object.keys(v).every(k => allowed.includes(k))
11
12/** No expansion or evaluation: only literal shell words in one simple command. */
13export function literalWords(command: string): string[] | undefined {
14 const words: string[] = []
15 let word = ''
16 let active = false
17 let quote = ''
18 for (let n = 0; n < command.length; n++) {
19 const c = command[n]!
20 if (quote === "'") {
21 if (c === "'") quote = ''
22 else word += c
23 } else if (c === '\\') {
24 const next = command[n + 1]
25 if (next === undefined || next === '\n') return undefined
26 if (quote === '"' && !'"\\$`'.includes(next)) word += c
27 else {
28 word += next
29 n++
30 }
31 active = true
32 } else if (quote === '"') {
33 if (c === '"') quote = ''
34 else if ('$`'.includes(c)) return undefined
35 else word += c
36 } else if (c === '"' || c === "'") {
37 quote = c
38 active = true
39 } else if (c === ' ' || c === '\t') {
40 if (active) words.push(word)
41 word = ''
42 active = false
43 } else {
44 if ('\n\r\0|&;<>($`)*?[]{}~#!'.includes(c)) return undefined
45 word += c
46 active = true
47 }
48 }
49 if (quote) return undefined
50 if (active) words.push(word)
51 return words.length ? words : undefined
52}
53
54// Lexical resolution only. Never sample today's filesystem to explain old output.
55const pathAt = (cwd: string, path: string): string | undefined => {
56 if (!cwd.startsWith('/') || !path || /[\0\r\n]/.test(path)) return undefined
57 const parts: string[] = []
58 for (const p of (path.startsWith('/') ? path : `${cwd}/${path}`).split('/')) {
59 // Resolving .. without the source filesystem can cross a symlink differently.
60 if (p === '..') return undefined
61 if (p && p !== '.') parts.push(p)
62 }
63 return '/' + parts.join('/')
64}
65
66const nulPaths = (output: string): string[] | undefined => {
67 if (!output) return []
68 if (!output.endsWith('\0')) return undefined
69 const paths = output.slice(0, -1).split('\0')
70 return paths.every(p => p !== '' && !/[\r\n]/.test(p)) ? paths : undefined
71}
72
73const globPattern = (pattern: string): string | undefined => {
74 // A single basename filter has the same recursive scope in rg and Glob.
75 if (!pattern || /[\/\\!{}\[\]\r\n]/.test(pattern)) return undefined
76 return `**/${pattern}`
77}
78
79/** Undefined means that Bash is the truthful representation. */
80function parseExploration(source: SourceRecord | undefined): Action | undefined {
81 const item = source?.item
82 if (!item || item.type !== 'commandExecution' || !['agent', 'unifiedExecStartup'].includes(item.source) ||
83 !['completed', 'failed'].includes(item.status) || ![0, 1].includes(item.exitCode) ||
84 typeof item.command !== 'string' || typeof item.aggregatedOutput !== 'string' ||
85 !Array.isArray(item.commandActions) || item.commandActions.length !== 1) return undefined
86 const action = item.commandActions[0]
87 let command = item.command
88 let args = literalWords(command)
89 // Decode exactly one shell -c argument, never a suffix of a compound command.
90 if (args?.length === 3 && /^(?:\/(?:usr\/)?bin\/)?(?:ba|z|da)?sh$/.test(args[0]!) && ['-c', '-lc'].includes(args[1]!)) {
91 command = args[2]!
92 args = literalWords(command)
93 }
94 if (!action || (action.command !== item.command && action.command !== command)) return undefined
95 const cwd = source!.cwd
96 const output: string = item.aggregatedOutput
97 if (!args || !cwd?.startsWith('/') || item.stderr || item.error || item.failure || item.timedOut || item.interrupted || item.truncated === true ||
98 /(?:\[.*truncated.*\]|Warning: truncated output|tokens truncated)/i.test(output)) return undefined
99 const program = args.shift()
100 if (action.type === 'read') {
101 if (item.status !== 'completed' || item.exitCode !== 0 || output.includes('\0')) return undefined
102 let offset = 1
103 let limit: number | undefined
104 let total = lines(output)
105 if (program === 'head' && args[0] === '-n' && /^[1-9]\d*$/.test(args[1] ?? '')) {
106 limit = Number(args.splice(0, 2)[1])
107 // A full window does not establish the required file total.
108 if (!integer(limit) || total >= limit) return undefined
109 } else if (program === 'tail' && args[0] === '-n' && /^\+[1-9]\d*$/.test(args[1] ?? '')) {
110 offset = Number(args.splice(0, 2)[1]!.slice(1))
111 if (!integer(offset) || (offset > 1 && !output)) return undefined
112 total += offset - 1
113 } else if (program !== 'cat') return undefined
114 if (args[0] === '--') args.shift()
115 if (args.length !== 1 || args[0]!.startsWith('-') || args[0] === '-') return undefined
116 const path = pathAt(cwd, args[0]!)
117 if (!path || typeof action.path !== 'string' || pathAt(cwd, action.path) !== path) return undefined
118 return { tool: 'Read', input: { file_path: path, ...(offset !== 1 ? { offset } : {}), ...(limit !== undefined ? { limit } : {}) },
119 result: { type: 'text', file: { filePath: path, content: output, numLines: lines(output), startLine: offset, totalLines: total } }, source }
120 }
121 const root = action.path == null ? cwd : typeof action.path === 'string' ? pathAt(cwd, action.path) : undefined
122 if (!root) return undefined
123 if (action.type === 'listFiles') {
124 if (item.status !== 'completed' || item.exitCode !== 0) return undefined
125 let pattern: string | undefined
126 let target = '.'
127 if (program === 'find') {
128 target = args.shift() ?? ''
129 if (target.startsWith('-')) return undefined
130 if (JSON.stringify(args) === JSON.stringify(['-maxdepth', '1', '-type', 'f', '-print0'])) pattern = '*'
131 if (JSON.stringify(args) === JSON.stringify(['-type', 'f', '-print0'])) pattern = '**/*'
132 } else if (program === 'rg') {
133 let files = false
134 let nul = false
135 let filter: string | undefined
136 const paths: string[] = []
137 for (let n = 0; n < args.length; n++) {
138 const arg = args[n]!
139 if (arg === '--files' && !files) files = true
140 else if ((arg === '-0' || arg === '--null') && !nul) nul = true
141 else if ((arg === '-g' || arg === '--glob') && filter === undefined) {
142 filter = args[++n]
143 if (filter === undefined) return undefined
144 } else if (!arg.startsWith('-')) paths.push(arg)
145 else return undefined
146 }
147 if (!files || !nul || paths.length > 1) return undefined
148 target = paths[0] ?? '.'
149 pattern = filter === undefined ? '**/*' : globPattern(filter)
150 }
151 if (!pattern || pathAt(cwd, target) !== root) return undefined
152 const reported = nulPaths(output)
153 if (!reported) return undefined
154 const filenames = reported.map(p => pathAt(cwd, p))
155 if (filenames.some(p => !p)) return undefined
156 const elapsed = item.durationMs ?? (source!.startedAtMs !== undefined && source!.completedAtMs !== undefined ? source!.completedAtMs - source!.startedAtMs : undefined)
157 return { tool: 'Glob', input: { pattern, path: root },
158 result: { durationMs: typeof elapsed === 'number' && elapsed >= 0 ? elapsed : 0, numFiles: filenames.length, filenames: filenames as string[], truncated: false }, source }
159 }
160 if (action.type !== 'search' || program !== 'rg') return undefined
161 const input: Record<string, any> = { path: root, output_mode: 'content', head_limit: 0 }
162 let fixed = false
163 let json = false
164 let nul = false
165 let withFilename = false
166 let query: string | undefined
167 const paths: string[] = []
168 let options = true
169 for (let n = 0; n < args.length; n++) {
170 const arg = args[n]!
171 if (options && arg === '--') { options = false; continue }
172 if (options && arg.startsWith('-')) {
173 if (arg === '--json') json = true
174 else if (arg === '-0' || arg === '--null') nul = true
175 else if (arg === '-H' || arg === '--with-filename') withFilename = true
176 else if (arg === '-l' || arg === '--files-with-matches') input.output_mode = 'files_with_matches'
177 else if (arg === '--count-matches') input.output_mode = 'count'
178 else if (arg === '-F' || arg === '--fixed-strings') fixed = true
179 else if (['-i', '-n', '-o'].includes(arg)) input[arg] = true
180 else if (['-g', '--glob', '-t', '--type', '-A', '-B', '-C'].includes(arg)) {
181 const value = args[++n]
182 if (value === undefined) return undefined
183 if (arg === '-g' || arg === '--glob') {
184 if (input.glob !== undefined || !globPattern(value)) return undefined
185 input.glob = value
186 } else if (arg === '-t' || arg === '--type') {
187 if (input.type !== undefined || !/^[\w-]+$/.test(value)) return undefined
188 input.type = value
189 } else {
190 if (!/^\d+$/.test(value) || !integer(Number(value))) return undefined
191 input[arg] = Number(value)
192 }
193 } else return undefined
194 } else if (query === undefined) query = arg
195 else paths.push(arg)
196 }
197 // With no explicit path rg can read inherited stdin instead of the cwd.
198 if (query === undefined || query !== action.query || paths.length !== 1 || paths[0] === '-' || pathAt(cwd, paths[0]!) !== root) return undefined
199 input.pattern = fixed ? query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : query
200 const mode = input.output_mode as GrepResult['mode']
201 if ((mode === 'content' && (!json || nul || withFilename)) || (mode !== 'content' && (json || !nul)) ||
202 (mode === 'count' && (!withFilename || input['-o'])) || (mode !== 'content' && ['-A', '-B', '-C'].some(k => k in input))) return undefined
203 // rg's documented 1 means no matches only when it produced no error/output.
204 if (item.exitCode === 1 && output !== '' && mode !== 'content') return undefined
205 if (item.exitCode === 0 && item.status !== 'completed') return undefined
206 const filenames: string[] = []
207 let count = 0
208 let content = ''
209 let numLines = 0
210 const add = (p: string) => {
211 const path = pathAt(cwd, p)
212 if (!path) return false
213 if (!filenames.includes(path)) filenames.push(path)
214 return true
215 }
216 if (output && mode === 'content') {
217 if (!output.endsWith('\n')) return undefined
218 try {
219 let matches = 0
220 let emptySummary = false
221 for (const line of output.slice(0, -1).split('\n')) {
222 const record = JSON.parse(line)
223 if (!['begin', 'match', 'context', 'end', 'summary'].includes(record.type)) return undefined
224 if (record.type === 'summary') emptySummary = record.data?.stats?.matches === 0 && record.data?.stats?.matched_lines === 0
225 if (record.type === 'match' || record.type === 'context') {
226 const data = record.data
227 if (typeof data?.path?.text !== 'string' || data.path.text === '<stdin>' || typeof data?.lines?.text !== 'string' || data.lines.text.includes('\0') || !add(data.path.text)) return undefined
228 if (input['-o']) return undefined // JSON mode reports full lines even with -o.
229 if (input['-n'] && (!integer(data.line_number) || data.line_number < 1)) return undefined
230 const text: string = data.lines.text
231 const delivered = lines(text)
232 const pieces = text.split('\n')
233 if (content && !content.endsWith('\n') && delivered) content += '\n'
234 for (let n = 0; n < delivered; n++) {
235 const sep = record.type === 'context' ? '-' : ':'
236 content += `${data.path.text}${input['-n'] ? `${sep}${data.line_number + n}` : ''}${sep}${pieces[n]}${n < delivered - 1 || text.endsWith('\n') ? '\n' : ''}`
237 }
238 numLines += delivered
239 if (record.type === 'match') matches++
240 }
241 }
242 if (item.exitCode === 1 ? matches !== 0 || filenames.length !== 0 || !emptySummary : matches === 0) return undefined
243 } catch { return undefined }
244 } else if (mode === 'files_with_matches') {
245 const reported = nulPaths(output)
246 if (!reported || !reported.every(add)) return undefined
247 } else if (output && mode === 'count') {
248 let rest = output
249 while (rest) {
250 const end = rest.indexOf('\0')
251 if (end < 1 || !add(rest.slice(0, end))) return undefined
252 rest = rest.slice(end + 1)
253 const match = /^(\d+)\n/.exec(rest)
254 if (!match || !integer(Number(match[1]))) return undefined
255 count += Number(match[1])
256 rest = rest.slice(match[0].length)
257 }
258 }
259 return { tool: 'Grep', input,
260 result: { mode, numFiles: filenames.length, filenames,
261 ...(mode === 'content' ? { content, numLines } : {}),
262 ...(mode === 'count' ? { content: output, numMatches: count } : {}) }, source }
263}
264
265export function explorationAction(source: SourceRecord | undefined): Action | undefined {
266 const action = parseExploration(source)
267 return action && validExploration(action) ? action : undefined
268}
269
270/** Exact local subset of R1. No _host routing or extra provenance schema keys. */
271export function validExploration(a: Action): boolean {
272 const i = a.input
273 const r: any = a.result
274 if (!r || Array.isArray(r)) return false
275 const path = (p: unknown) => typeof p === 'string' && p.startsWith('/') && !/[\0\r\n]/.test(p)
276 if (a.tool === 'Read') return keys(i, ['file_path', 'offset', 'limit']) && path(i.file_path) &&
277 (i.offset === undefined || (integer(i.offset) && i.offset > 0)) && (i.limit === undefined || (integer(i.limit) && i.limit > 0)) &&
278 keys(r, ['type', 'file']) && r.type === 'text' && r.file && typeof r.file === 'object' && !Array.isArray(r.file) &&
279 keys(r.file, ['filePath', 'content', 'numLines', 'startLine', 'totalLines']) && r.file.filePath === i.file_path &&
280 typeof r.file.content === 'string' && r.file.numLines === lines(r.file.content) && r.file.startLine === (i.offset ?? 1) &&
281 integer(r.file.totalLines) && r.file.totalLines >= r.file.startLine - 1 + r.file.numLines &&
282 (i.limit === undefined || r.file.numLines <= i.limit)
283 if (!Array.isArray(r.filenames) || !r.filenames.every(path) || r.numFiles !== r.filenames.length) return false
284 if (a.tool === 'Glob') return keys(i, ['pattern', 'path']) && typeof i.pattern === 'string' && path(i.path) &&
285 keys(r, ['durationMs', 'numFiles', 'filenames', 'truncated']) && typeof r.durationMs === 'number' && Number.isFinite(r.durationMs) && r.durationMs >= 0 && r.truncated === false
286 if (a.tool !== 'Grep' || !keys(i, ['pattern', 'path', 'output_mode', 'glob', '-n', '-i', '-o', '-A', '-B', '-C', 'type', 'multiline', 'head_limit', 'offset']) ||
287 typeof i.pattern !== 'string' || !path(i.path) || !['content', 'files_with_matches', 'count'].includes(String(i.output_mode)) ||
288 !['glob', 'type'].every(k => i[k] === undefined || typeof i[k] === 'string') ||
289 !['-n', '-i', '-o', 'multiline'].every(k => i[k] === undefined || typeof i[k] === 'boolean') ||
290 !['-A', '-B', '-C', 'head_limit', 'offset'].every(k => i[k] === undefined || integer(i[k])) || r.mode !== i.output_mode) return false
291 return keys(r, ['mode', 'numFiles', 'filenames', 'content', 'numLines', 'numMatches']) &&
292 (r.mode === 'files_with_matches' ? r.content === undefined && r.numLines === undefined && r.numMatches === undefined :
293 typeof r.content === 'string' && (r.mode === 'content' ? integer(r.numLines) && r.numLines === lines(r.content) && r.numMatches === undefined : integer(r.numMatches) && r.numLines === undefined))
294}
295hooks/patches.ts 185 lines1import type { Action, FileChange } from './codex.ts'
2
3// R1 Bash/Edit/Write contracts; source grammar is unified per-file diffs (R2).
4export type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
5type ParsedHunk = { hunk: Hunk; old: string[]; next: string[] }
6export const PATCH_TEXT_MAX = 100000
7const quote = (s: string) => `'${s.replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/'/g, `'\\''`)}'`
8const text = (s: string | undefined): s is string => s !== undefined && !s.includes('\0') && !s.includes('\ufffd')
9const fileLines = (s: string): string[] => s.match(/[^\n]*\n|[^\n]+$/g) ?? []
10
11/** Keep payload CRs and EOF markers: splitting/trimEnd would change file bytes. */
12export function parsePatch(diff: string, kind: string): ParsedHunk[] | undefined {
13 if (diff.includes('\0') || diff.includes('\ufffd') || (!diff.endsWith('\n') && !diff.endsWith('\\ No newline at end of file'))) return undefined
14 const lines = diff.split('\n')
15 if (lines.at(-1) === '') lines.pop()
16 if (!lines.length) return undefined
17 const parsed: ParsedHunk[] = []
18 let current: ParsedHunk | undefined
19 let previous = ''
20 const raw = !lines.some(l => l.startsWith('@@')) && ['add', 'delete'].includes(kind)
21 if (raw) {
22 current = { hunk: { oldStart: kind === 'add' ? 0 : 1, oldLines: 0, newStart: kind === 'add' ? 1 : 0, newLines: 0, lines: [] }, old: [], next: [] }
23 parsed.push(current)
24 }
25 for (const line of lines) {
26 const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@[^\r\n]*\r?$/.exec(line)
27 if (header) {
28 current = { hunk: { oldStart: Number(header[1]), oldLines: Number(header[2] ?? 1), newStart: Number(header[3]), newLines: Number(header[4] ?? 1), lines: [] }, old: [], next: [] }
29 parsed.push(current)
30 previous = ''
31 } else if (!current && /^(diff --git |index |--- |\+\+\+ |new file mode |deleted file mode |old mode |new mode )/.test(line)) {
32 continue
33 } else if (current && line === '\\ No newline at end of file') {
34 if (!previous) return undefined
35 if (' -'.includes(previous)) current.old[current.old.length - 1] = current.old.at(-1)!.slice(0, -1)
36 if (' +'.includes(previous)) current.next[current.next.length - 1] = current.next.at(-1)!.slice(0, -1)
37 current.hunk.lines.push(line)
38 previous = ''
39 } else if (current && /^[ +\-]/.test(line)) {
40 const prefix = line[0]!
41 if (raw && prefix !== (kind === 'add' ? '+' : '-')) return undefined
42 const payload = line.slice(1) + '\n'
43 if (prefix !== '+') current.old.push(payload)
44 if (prefix !== '-') current.next.push(payload)
45 current.hunk.lines.push(line)
46 previous = prefix
47 } else return undefined
48 }
49 if (raw && current) {
50 current.hunk.oldLines = current.old.length
51 current.hunk.newLines = current.next.length
52 }
53 if (!parsed.length || parsed.some(p => p.old.length !== p.hunk.oldLines || p.next.length !== p.hunk.newLines ||
54 (kind === 'add' && p.old.length !== 0) || (kind === 'delete' && p.next.length !== 0) ||
55 ![p.hunk.oldStart, p.hunk.oldLines, p.hunk.newStart, p.hunk.newLines].every(Number.isSafeInteger))) return undefined
56 return parsed
57}
58
59/** Reject offsets, fuzzy matches, overlapping hunks and nonterminal EOF markers. */
60function apply(before: string, parsed: ParsedHunk[]): string | undefined {
61 const lines = fileLines(before)
62 const out: string[] = []
63 let cursor = 0
64 let delta = 0
65 for (const p of parsed) {
66 const start = p.hunk.oldStart - (p.hunk.oldLines ? 1 : 0)
67 const nextStart = p.hunk.newStart - (p.hunk.newLines ? 1 : 0)
68 if (start < cursor || start > lines.length || nextStart !== start + delta ||
69 p.old.join('') !== lines.slice(start, start + p.old.length).join('')) return undefined
70 for (const line of lines.slice(cursor, start)) out.push(line)
71 for (const line of p.next) out.push(line)
72 cursor = start + p.old.length
73 delta += p.next.length - p.old.length
74 }
75 for (const line of lines.slice(cursor)) out.push(line)
76 if (out.slice(0, -1).some(l => !l.endsWith('\n'))) return undefined
77 return out.join('')
78}
79
80const unique = (whole: string, part: string) => part !== '' && whole.indexOf(part) === whole.lastIndexOf(part)
81
82function edit(before: string, parsed: ParsedHunk[], after: string): Action | undefined {
83 const lines = fileLines(before)
84 const first = parsed[0]!
85 const last = parsed.at(-1)!
86 let start = first.hunk.oldStart - (first.hunk.oldLines ? 1 : 0)
87 let end = last.hunk.oldStart - (last.hunk.oldLines ? 1 : 0) + last.old.length
88 const delta = after.length - before.length
89 let context = 1
90 // Real neighbouring lines make pure insertions and repeated text unambiguous.
91 for (;;) {
92 const offset = lines.slice(0, start).join('').length
93 const old = lines.slice(start, end).join('')
94 const next = after.slice(offset, offset + old.length + delta)
95 if (old.length > PATCH_TEXT_MAX || next.length > PATCH_TEXT_MAX) return undefined
96 if (unique(before, old) && before.slice(0, offset) + next + before.slice(offset + old.length) === after) {
97 return { tool: 'Edit', input: { old_string: old, new_string: next, replace_all: false }, result: {
98 oldString: old, newString: next, originalFile: before.length <= PATCH_TEXT_MAX ? before : null,
99 structuredPatch: parsed.map(p => p.hunk), userModified: false, replaceAll: false,
100 } }
101 }
102 if (start > 0) start = Math.max(0, start - context)
103 else if (end < lines.length) end = Math.min(lines.length, end + context)
104 else return undefined
105 context *= 2
106 }
107}
108
109function imageHunk(content: string, created: boolean): Hunk[] {
110 const lines = fileLines(content)
111 if (!lines.length) return []
112 return [{ oldStart: created ? 0 : 1, oldLines: created ? 0 : lines.length,
113 newStart: created ? 1 : 0, newLines: created ? lines.length : 0,
114 lines: lines.flatMap(l => [(created ? '+' : '-') + (l.endsWith('\n') ? l.slice(0, -1) : l), ...(!l.endsWith('\n') ? ['\\ No newline at end of file'] : [])]),
115 }]
116}
117
118/** A patch is evidence, never a shell command or permission to replay a write. */
119export function patchActions(change: FileChange, before: string | undefined, after: string | undefined,
120 status: string | undefined, report = '', stable = true, fallbackReason?: string): Action[] {
121 const kind = typeof change.kind === 'string' ? change.kind : change.kind?.type ?? 'unknown'
122 const move = typeof change.kind === 'object' ? change.kind?.move_path : change.move_path
123 const destination = typeof move === 'string' ? move : undefined
124 const path = change.path
125 const operation = destination ? 'move' : ['add', 'update', 'delete'].includes(kind) ? kind : 'unknown'
126 const label = destination ? `${quote(path)} → ${quote(destination)}` : quote(path)
127 const parsed = typeof change.diff === 'string' ? parsePatch(change.diff, kind) : undefined
128 const actualHunks = parsed?.map(p => p.hunk)
129 const raw = change.diff === undefined ? '' : `\nReported diff:\n${change.diff}`
130 const summary = destination ? `Codex moved ${path} → ${destination}` : `Codex apply_patch ${kind}: ${path}`
131 const fallback = (reason: string, files?: Record<string, unknown>[]): Action[] => [{
132 tool: 'Bash', input: { command: `# Codex apply_patch: ${operation} ${label}`, description: `Codex patch: ${operation} ${label}` },
133 result: { stdout: `${summary}\n${reason}${report ? `\n${report}` : ''}${raw}`, stderr: '', interrupted: false,
134 ...(files ? { bashEditDiff: { files, moreFiles: 0, changedFiles: files.map(f => f.filePath) } } : {}),
135 },
136 }]
137 if (status !== 'completed') {
138 const state = status === 'declined' ? 'declined' : status === 'failed' ? 'failed' : 'outcome unconfirmed'
139 const rows = fallback(`Patch ${state}; individual application is unproved.`)
140 rows[0]!.input.description = `Codex patch ${state}: ${operation} ${label}`
141 rows[0]!.error = `Codex patch ${state}: ${operation} ${label}\n${report}${raw}`
142 return rows
143 }
144 if (destination) {
145 const verified = stable && text(before) && text(after) &&
146 (parsed ? apply(before, parsed) === after : change.diff === '' && before === after)
147 return fallback(verified ? 'Verified move images; modification diff retained below.' : 'Move reported; exact old/new images unavailable.', verified ? [
148 { filePath: path, hunks: imageHunk(before!, false), deleted: true },
149 { filePath: destination, hunks: imageHunk(after!, true), created: true },
150 ] : undefined)
151 }
152 if (kind === 'delete') return fallback('Deletion reported by completed source patch.', actualHunks ? [{ filePath: path, hunks: actualHunks, deleted: true }] : undefined)
153 const patchFiles = actualHunks ? [{ filePath: path, hunks: actualHunks, ...(kind === 'add' ? { created: true } : {}) }] : undefined
154 if (fallbackReason) return fallback(fallbackReason, patchFiles)
155 if (kind === 'add') {
156 // Empty files need a completed image; an empty/missing diff alone is ambiguous.
157 const content = parsed ? apply('', parsed) : change.diff === '' && after === '' ? '' : undefined
158 if (text(content) && stable && (after === undefined || after === content) && content.length <= PATCH_TEXT_MAX) return [{
159 tool: 'Write', input: { file_path: path, content }, result: { type: 'create', filePath: path, content, structuredPatch: actualHunks ?? [], originalFile: null },
160 }]
161 return fallback('Exact complete addition unavailable, oversized, binary, or completed image disagrees.', patchFiles)
162 }
163 if (kind !== 'update' || !parsed || !text(before) || !text(after) || !stable || apply(before, parsed) !== after) {
164 return fallback('Exact update unavailable: missing/invalid diff or base, binary text, or racing completed image.', patchFiles)
165 }
166 const bind = (a: Action) => ({ ...a, input: { file_path: path, ...a.input }, result: { ...a.result, filePath: path } })
167 const combined = edit(before, parsed, after)
168 if (combined) return [bind(combined)]
169 // A bounded combined span can be unavailable while each hunk is exact.
170 const rows: Action[] = []
171 let intermediate = before
172 let delta = 0
173 for (const p of parsed) {
174 const adjusted = { ...p, hunk: { ...p.hunk, oldStart: p.hunk.oldStart + delta } }
175 const next = apply(intermediate, [adjusted])
176 if (next === undefined) return fallback('No exact intermediate hunk revision.', patchFiles)
177 const row = edit(intermediate, [adjusted], next)
178 if (!row) return fallback('Not every hunk has a unique bounded replacement.', patchFiles)
179 rows.push(bind(row))
180 intermediate = next
181 delta += p.next.length - p.old.length
182 }
183 return intermediate === after ? rows : fallback('Hunk revisions disagree with completed image.', patchFiles)
184}
185hooks/web-tools.ts 107 lines1import type { Action, SourceRecord } from './codex.ts'
2
3const object = (v: unknown): v is Record<string, any> => v !== null && typeof v === 'object' && !Array.isArray(v)
4const number = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0
5const text = (v: unknown): v is string => typeof v === 'string' && v.length > 0
6const FETCH_FIELDS = ['bytes', 'code', 'codeText', 'result', 'durationMs', 'url']
7
8/** Keep media metadata readable without copying encoded bytes into a text row. */
9export function publicToolText(value: unknown): string {
10 const safe = (v: any): any => {
11 if (Array.isArray(v)) return v.map(safe)
12 if (typeof v === 'string' && /^data:/i.test(v)) return `[media URL omitted; ${v.length} characters]`
13 if (!object(v)) return v
14 const media = ['image', 'audio', 'inputImage', 'inputAudio'].includes(v.type)
15 return Object.fromEntries(Object.entries(v).map(([k, child]) => [k,
16 ((media && ['data', 'imageUrl', 'audioUrl'].includes(k)) || k === 'blob') && typeof child === 'string'
17 ? `[encoded payload omitted; ${child.length} characters]` : safe(child)]))
18 }
19 return typeof value === 'string' ? safe(value) : JSON.stringify(safe(value)) ?? String(value)
20}
21
22export function sourceDuration(source?: SourceRecord): number | undefined {
23 const d = source?.item?.durationMs ?? source?.item?.duration_ms
24 if (number(d)) return d
25 if (number(source?.startedAtMs) && number(source?.completedAtMs) && source.completedAtMs >= source.startedAtMs) return source.completedAtMs - source.startedAtMs
26 return undefined
27}
28
29function parsedResults(value: any): any {
30 if (typeof value !== 'string') return value
31 try { return JSON.parse(value) } catch { return value }
32}
33
34/** R1 WebSearch's union retains opaque/text entries alongside actual sources. */
35function searchResults(value: any): any[] {
36 value = parsedResults(value)
37 if (!Array.isArray(value)) return [publicToolText(value)]
38 const results: any[] = []
39 for (const entry of value) {
40 if (object(entry) && typeof entry.title === 'string' && typeof entry.url === 'string') {
41 let last = results.at(-1)
42 if (!object(last)) results.push(last = { tool_use_id: '', content: [] })
43 last.content.push({ title: entry.title, url: entry.url, ...(typeof entry.snippet === 'string' ? { snippet: entry.snippet } : {}) })
44 } else results.push(publicToolText(entry))
45 }
46 return results
47}
48
49/** RFetch requires reported response facts, never guessed HTTP success/bytes. */
50export function validFetchResult(r: any, url: unknown): boolean {
51 return object(r) && r.url === url && number(r.bytes) && Number.isSafeInteger(r.bytes) &&
52 Number.isInteger(r.code) && r.code >= 100 && r.code <= 599 && typeof r.codeText === 'string' &&
53 typeof r.result === 'string' && number(r.durationMs) &&
54 Object.keys(r).every(k => FETCH_FIELDS.includes(k))
55}
56
57export function webFallback(item: any, reason: string): Action {
58 return {
59 tool: 'codex_tool', input: { server: 'web', name: item.action?.type ?? 'other', arguments: { query: item.query ?? null, action: item.action ?? null } },
60 result: [{ type: 'text', text: `${reason}\n${publicToolText({ results: item.results ?? null, ...(item.error != null ? { error: item.error } : {}) })}` }],
61 ...(item.error != null || ['failed', 'declined'].includes(item.status) ? { error: `Codex web action failed/was declined: ${publicToolText(item)}` } : {}),
62 }
63}
64
65export function webAction(item: any, source?: SourceRecord): Action {
66 const a = item.action
67 if (['openPage', 'findInPage'].includes(a?.type)) {
68 const values = parsedResults(item.results)
69 const r = Array.isArray(values) && values.length === 1 ? values[0] : values
70 const duration = sourceDuration(source) ?? sourceDuration({ transport: 'exec', event: 'item.completed', item })
71 const response = object(r) ? Object.fromEntries(FETCH_FIELDS.map(k => [k, k === 'durationMs' ? r[k] ?? duration : r[k]])) : undefined
72 if (!text(a.url) || (a.type === 'findInPage' && typeof a.pattern !== 'string') || !validFetchResult(response, a.url)) {
73 return webFallback(item, 'WebFetch requires a URL, find pattern and reported bytes/code/codeText/text/duration/url metadata.')
74 }
75 return {
76 tool: 'WebFetch', input: { url: a.url, prompt: a.type === 'openPage' ? 'Show the page content reported by Codex' : `Find ${JSON.stringify(a.pattern)} in the page reported by Codex` },
77 result: response!, ...(item.error != null || item.status === 'declined' || (item.status === 'failed' && response!.code < 400)
78 ? { error: `Codex web action failed/was declined: ${publicToolText(item)}` } : {}),
79 }
80 }
81 if (a != null && a.type !== 'search') return webFallback(item, 'Unrepresentable web action; preserving the reported action and results.')
82 const query = text(a?.query) ? a.query
83 : Array.isArray(a?.queries) && a.queries.length > 0 && a.queries.every(text) ? a.queries.join('\n') : item.query
84 if (!text(query)) return webFallback(item, 'WebSearch requires a reported nonempty search query.')
85 return {
86 tool: 'WebSearch', input: { query, mode: item.mode === 'extended' || a?.mode === 'extended' ? 'extended' : 'standard' },
87 result: { query, results: searchResults(item.results), durationSeconds: (sourceDuration(source) ?? 0) / 1000 },
88 ...(item.error != null || ['failed', 'declined'].includes(item.status) ? { error: `Codex web search failed/was declined: ${publicToolText(item)}` } : {}),
89 }
90}
91
92/** ContentItems are dynamic input* variants, not renamed MCP content blocks. */
93export function toolFallback(item: any, reason: string): Action {
94 const dynamic = ['dynamicToolCall', 'dynamic_tool_call'].includes(item.type)
95 const blocks = dynamic ? item.contentItems ?? item.content_items : item.result?.content
96 const texts = Array.isArray(blocks) ? blocks.filter(b => object(b) && b.type === (dynamic ? 'inputText' : 'text') && typeof b.text === 'string').map(b => b.text) : []
97 const failed = item.error != null || ['failed', 'declined'].includes(item.status) || item.success === false || item.result?.is_error === true || item.result?.isError === true
98 const report = publicToolText(dynamic
99 ? { contentItems: blocks ?? null, success: item.success ?? null, status: item.status ?? null, durationMs: item.durationMs ?? item.duration_ms ?? null }
100 : { result: item.result ?? null, error: item.error ?? null, status: item.status ?? null, durationMs: item.durationMs ?? item.duration_ms ?? null })
101 const output = `${reason}\n${texts.join('\n')}${texts.length ? '\n' : ''}${report}`
102 return {
103 tool: 'codex_tool', input: { server: item.server ?? item.namespace ?? 'dynamic', name: item.tool ?? '', arguments: item.arguments === undefined ? null : item.arguments },
104 result: [{ type: 'text', text: output }], ...(failed ? { error: `Codex tool call failed/was declined: ${output}` } : {}),
105 }
106}
107hooks/plans-children.ts 169 lines1import type { Action } from './codex.ts'
2
3type Status = 'pending' | 'in_progress' | 'completed'
4export type PlanInput = { steps: { step: string; status: Status }[]; explanation?: string }
5type PlanStep = PlanInput['steps'][number] & { id: string }
6export type DisplayState = {
7 plans: Record<string, { revision: number; counter: number; steps: PlanStep[]; explanation?: string; surface?: string }>
8 children: Record<string, ChildIdentity>
9}
10// Source identity only. A display ID is never a host task or an attachable mirror.
11export type ChildIdentity = {
12 claudeSessionId: string; parentCodexThreadId: string; codexThreadId: string; parentClaudeAgentId: string; displayId: string
13 sourceTurnId?: string; path?: string; model?: string; effort?: string; status?: string; message?: string
14 sourceSpawnTurnId?: string; sourceSpawnItemId?: string
15 interactionPending?: boolean
16}
17export type Row = { kind: 'action'; action: Action; display?: 'codex_tool' | 'codex_plan' } | { kind: 'note'; text: string }
18type Context = { session: string; agent: string; thread: string; turn?: string; rich: boolean; tools: Set<string> }
19export const emptyDisplayState = (): DisplayState => ({ plans: {}, children: {} })
20const short = (s: string, n = 120) => s.replace(/\s+/g, ' ').trim().slice(0, n)
21const textResult = (text: string) => [{ type: 'text' as const, text }]
22const component = (s: string) => encodeURIComponent(s)
23export const displayChildId = (session: string, child: string) => `codex-display-${component(session)}-${component(child)}`
24export const planText = (input: PlanInput) => [input.explanation, 'Codex plan (display only):',
25 ...input.steps.map(s => `${s.status === 'completed' ? '[x]' : s.status === 'in_progress' ? '[-]' : '[ ]'} ${s.step}`),
26 ...(input.steps.length ? [] : ['(empty checklist)'])].filter(s => s !== undefined).join('\n')
27
28/** Snapshots and IDs belong only to this agent's source thread, including after resume. */
29export function planRows(input: PlanInput, state: DisplayState, ctx: Context): Row[] {
30 const key = ctx.thread
31 const old = Object.prototype.hasOwnProperty.call(state.plans, key) ? state.plans[key]! : { revision: 0, counter: 0, steps: [] }
32 const surface = ctx.rich && ctx.tools.has('TodoWrite') ? 'todo' : ctx.rich && ctx.tools.has('TaskCreate') && ctx.tools.has('TaskUpdate') ? 'tasks' : 'display'
33 if (old.revision && old.surface === surface && old.explanation === input.explanation && JSON.stringify(old.steps.map(({ step, status }) => ({ step, status }))) === JSON.stringify(input.steps)) return []
34 const revision = old.revision + 1
35 let counter = old.counter
36 const used = new Set<number>()
37 const matches = input.steps.map(s => {
38 if (surface === 'tasks' && old.surface !== 'tasks') return -1
39 const index = old.steps.findIndex((p, i) => !used.has(i) && p.step === s.step)
40 if (index >= 0) used.add(index)
41 return index
42 })
43 // Crossing occurrences have ambiguous identity after reordering. Remove/create
44 // them explicitly rather than transferring a different step's status or ID.
45 const crossed = new Set<number>()
46 for (let i = 0; i < matches.length; i++) for (let j = i + 1; j < matches.length; j++) {
47 if (matches[i]! >= 0 && matches[j]! >= 0 && matches[i]! > matches[j]!) { crossed.add(i); crossed.add(j) }
48 }
49 const steps = input.steps.map((s, i) => ({ ...s, id: matches[i]! >= 0 && !crossed.has(i)
50 ? old.steps[matches[i]!]!.id : `codex-plan-${component(ctx.agent + ':' + ctx.thread)}-${revision}-${++counter}` }))
51 Object.defineProperty(state.plans, key, { value: { revision, counter, steps, surface, ...(input.explanation !== undefined ? { explanation: input.explanation } : {}) }, enumerable: true, writable: true, configurable: true })
52 if (ctx.rich && ctx.tools.has('TodoWrite')) {
53 const todos = (ss: PlanStep[]) => ss.map(s => ({ content: s.step, status: s.status, activeForm: s.step }))
54 return [{ kind: 'action', action: { tool: 'TodoWrite', input: { todos: todos(steps) }, result: { oldTodos: todos(old.steps), newTodos: todos(steps) } } }]
55 }
56 if (ctx.rich && ctx.tools.has('TaskCreate') && ctx.tools.has('TaskUpdate')) {
57 const rows: Row[] = [{ kind: 'note', text: 'Codex plan tasks are display-only IDs; no host task records are created.\n' + planText(input) }]
58 const update = (id: string, changes: Record<string, string>, from: Status) => rows.push({ kind: 'action', action: {
59 tool: 'TaskUpdate', input: { taskId: id, ...changes }, result: { success: true, taskId: id, updatedFields: Object.keys(changes),
60 ...(changes.status && changes.status !== from ? { statusChange: { from, to: changes.status } } : {}) } } })
61 for (const p of old.surface === 'tasks' ? old.steps : []) if (!steps.some(s => s.id === p.id)) update(p.id, { status: 'deleted' }, p.status)
62 for (const s of steps) {
63 const previous = old.steps.find(p => p.id === s.id)
64 const description = [s.step, input.explanation].filter(v => v !== undefined).join('\n')
65 if (!previous) {
66 const subject = 'Codex plan: ' + s.step
67 rows.push({ kind: 'action', action: { tool: 'TaskCreate', input: { subject, description, activeForm: s.step,
68 metadata: { codexPlan: true, sourceThread: ctx.thread, sourceRevision: revision } }, result: { task: { id: s.id, subject } } } })
69 if (s.status !== 'pending') update(s.id, { status: s.status }, 'pending')
70 } else {
71 const changes: Record<string, string> = {}
72 if (previous.status !== s.status) changes.status = s.status
73 if (old.explanation !== input.explanation) changes.description = description
74 if (Object.keys(changes).length) update(s.id, changes, previous.status)
75 }
76 }
77 return rows
78 }
79 return [{ kind: 'note', text: !ctx.rich ? 'Native plan rows disabled (richRows:false); showing codex_plan.' : 'TodoWrite or paired TaskCreate/TaskUpdate unavailable in this agent; showing codex_plan.' },
80 { kind: 'action', display: 'codex_plan', action: { tool: 'codex_plan', input, result: textResult(planText(input)) } }]
81}
82
83const failed = (item: any) => ['failed', 'declined'].includes(item.status)
84const toolName = (item: any) => String(item.tool ?? '').replace(/_/g, '').toLowerCase()
85const statesOf = (item: any) => item.agentsStates ?? item.agents_states ?? {}
86const receiversOf = (item: any): string[] => {
87 const ids = item.receiverThreadIds ?? item.receiver_thread_ids ?? (item.agentThreadId ? [item.agentThreadId] : [])
88 return [...new Set<string>([...(Array.isArray(ids) ? ids.filter(id => typeof id === 'string' && id) : []), ...Object.keys(statesOf(item))])]
89}
90const sourceFields = (item: any) => Object.fromEntries(['receiverThreadIds', 'receiver_thread_ids', 'prompt', 'message', 'agentsStates', 'agents_states', 'status', 'result', 'error', 'model', 'reasoningEffort'].filter(k => item[k] !== undefined).map(k => [k, item[k]]))
91function fallback(item: any, reason: string): Row {
92 const report = `${reason}\nCodex ${item.tool ?? item.kind}:\n${JSON.stringify(sourceFields(item), null, 2)}`
93 return { kind: 'action', display: 'codex_tool', action: { tool: 'codex_tool', input: { server: 'codex', name: item.tool ?? item.kind, arguments: sourceFields(item) },
94 result: textResult(report), ...(failed(item) ? { error: report } : {}) } }
95}
96
97/** Completed operation rows never launch, message or stop a host agent. */
98export function childRows(item: any, state: DisplayState, ctx: Context): Row[] {
99 const ids = receiversOf(item)
100 const states = statesOf(item)
101 const tool = toolName(item)
102 const transitions: Row[] = []
103 for (const id of ids) {
104 const key = JSON.stringify([ctx.thread, id])
105 const previous = state.children[key]
106 const source = states[id] ?? {}
107 const activityStatus = ({ started: 'running', interrupted: 'interrupted', completed: 'completed' } as Record<string, string>)[item.kind]
108 const status = source.status ?? activityStatus
109 const child: ChildIdentity = { ...previous, claudeSessionId: previous?.claudeSessionId ?? ctx.session, parentCodexThreadId: ctx.thread, codexThreadId: id, parentClaudeAgentId: ctx.agent,
110 displayId: previous?.displayId ?? displayChildId(ctx.session, id), ...(ctx.turn ? { sourceTurnId: ctx.turn } : {}) }
111 if (tool === 'spawnagent' && !failed(item)) {
112 if (ctx.turn && !child.sourceSpawnTurnId) child.sourceSpawnTurnId = ctx.turn
113 if (typeof item.id === 'string' && !child.sourceSpawnItemId) child.sourceSpawnItemId = item.id
114 }
115 for (const [key, value] of Object.entries({ path: item.agentPath, model: source.model ?? item.model,
116 effort: source.reasoningEffort ?? item.reasoningEffort ?? item.reasoning_effort, status, message: source.message })) {
117 if (typeof value === 'string') (child as any)[key] = value
118 }
119 // A failed spawn has no successfully created child identity.
120 if (tool === 'spawnagent' && failed(item)) continue
121 state.children[key] = child
122 if (['sendinput', 'sendmessage', 'followuptask'].includes(tool) && item.status === 'completed') child.interactionPending = true
123 if (status && (previous?.status !== status || previous?.message !== child.message)) transitions.push({ kind: 'note',
124 text: `↪ Codex child ${id}${child.path ? ` (${child.path})` : ''}: ${status}${typeof item.durationMs === 'number' ? ` after ${item.durationMs} ms` : ''}${child.model ? `, model ${child.model}` : ''}${child.effort ? `, effort ${child.effort}` : ''}${child.message ? `\n${child.message}` : ''}` })
125 }
126 if (item.type === 'subAgentActivity' || item.type === 'sub_agent_activity') {
127 if (item.kind === 'interacted' && ids.some(id => state.children[JSON.stringify([ctx.thread, id])]?.interactionPending)) {
128 for (const id of ids) state.children[JSON.stringify([ctx.thread, id])].interactionPending = false
129 return transitions
130 }
131 if (item.kind === 'interacted' && !transitions.length) return [{ kind: 'note', text: `↪ Codex child ${ids.join(', ')} interacted${item.model ? `, model ${item.model}` : ''}${item.reasoningEffort ? `, effort ${item.reasoningEffort}` : ''}` }]
132 return transitions
133 }
134 if (tool === 'wait' || tool === 'waitagent') return transitions
135 const displayId = (id: string) => state.children[JSON.stringify([ctx.thread, id])]?.displayId ?? displayChildId(ctx.session, id)
136 if (tool === 'spawnagent' && ctx.rich && ctx.tools.has('Agent')) {
137 const prompt = typeof item.prompt === 'string' ? item.prompt : ''
138 const input = { description: short('Codex spawn: ' + prompt, 60), prompt, subagent_type: 'codex-mod:codex' }
139 if (failed(item)) return [{ kind: 'action', action: { tool: 'Agent', input, result: {}, error: `Codex spawn ${item.status}: ${JSON.stringify(item)}` } }]
140 if (item.status !== 'completed' || !ids.length || typeof item.prompt !== 'string') return [fallback(item, 'Spawn completion, child ID or actual prompt unreported.')]
141 const report = ids.map(id => `${id}: ${states[id]?.status ?? 'unknown'}${states[id]?.message ? `\n${states[id].message}` : ''}`).join('; ')
142 const model = item.model ?? states[ids[0]!]?.model
143 const effort = item.reasoningEffort ?? item.reasoning_effort
144 return [{ kind: 'action', action: { tool: 'Agent', input, result: {
145 agentId: displayId(ids[0]!), agentType: 'codex-mod:codex', status: 'completed', prompt, canContinueAgent: false,
146 content: textResult(`Codex spawn returned; child ${report}.${typeof model === 'string' ? ` Model ${model}.` : ''}${typeof effort === 'string' ? ` Effort ${effort}.` : ''} Display-only host operation: zero Claude tools, tokens and duration; Codex child usage and duration unreported. Child transcript unavailable; no host child task exists.`),
147 ...(typeof model === 'string' ? { resolvedModel: model } : {}), totalToolUseCount: 0, totalDurationMs: 0, totalTokens: 0,
148 usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: null, cache_read_input_tokens: null, server_tool_use: null, service_tier: null, cache_creation: null },
149 } } }]
150 }
151 if (ctx.rich && ids.length === 1 && ['sendinput', 'sendmessage', 'followuptask'].includes(tool) && ctx.tools.has('SendMessage')) {
152 const body = item.prompt ?? item.message
153 if (typeof body !== 'string' || !body) return [fallback(item, 'Actual message body unreported.')]
154 // The probed host mapper accepts this source acknowledgement object, but
155 // rejects null despite its declared unknown result. Never add delivery flags.
156 if (item.status !== 'completed' && !failed(item)) return [fallback(item, 'Message acknowledgement unreported.')]
157 return [{ kind: 'action', action: { tool: 'SendMessage', input: { to: { agentId: displayId(ids[0]!) }, message: body, summary: short(body) },
158 result: { status: item.status, agentsStates: states }, ...(failed(item) ? { error: `Codex ${item.tool} ${item.status}: ${JSON.stringify(item)}` } : {}) } }]
159 }
160 if (ctx.rich && ids.length === 1 && ['closeagent', 'interruptagent'].includes(tool) && ctx.tools.has('TaskStop')) {
161 if (item.status !== 'completed' && !failed(item)) return [fallback(item, 'Close/interrupt outcome unreported.')]
162 const task_id = displayId(ids[0]!)
163 return [{ kind: 'action', action: { tool: 'TaskStop', input: { task_id }, result: { task_id, task_type: 'codex',
164 message: `Codex ${tool === 'closeagent' ? 'closed' : 'interrupted'} child ${ids[0]}; display only.\n${JSON.stringify(sourceFields(item))}` },
165 ...(failed(item) ? { error: `Codex ${item.tool} ${item.status}: ${JSON.stringify(item)}` } : {}) } }]
166 }
167 return [fallback(item, !ctx.rich ? 'richRows disabled.' : ['resumeagent', 'listagents'].includes(tool) ? 'No equivalent host child lifecycle tool.' : 'Native child operation unavailable or target identity is ambiguous.')]
168}
169hooks/images-modes.ts 87 lines1import type { Action } from './codex.ts'
2import type { EngineInterface } from 'claude-code'
3
4export const IMAGE_MAX = 4 * 1024 * 1024
5const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
6
7/** Decode only the bounded bytes needed for sniffing; no Node globals in hooks. */
8const bytesAt = (base64: string, start: number, count: number): number[] => {
9 const out: number[] = []
10 for (let n = start; n < start + count; n++) {
11 const i = Math.floor(n / 3) * 4
12 const word = [0, 1, 2, 3].reduce((v, j) => (v << 6) | Math.max(0, alphabet.indexOf(base64[i + j]!)), 0)
13 out.push((word >>> (16 - (n % 3) * 8)) & 255)
14 }
15 return out
16}
17
18export function imageFile(base64: unknown): { base64: string; type: string; originalSize: number } | undefined {
19 if (typeof base64 !== 'string' || base64.length % 4 !== 0 || base64.length > Math.ceil(IMAGE_MAX / 3) * 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) return
20 const size = base64.length / 4 * 3 - (base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0)
21 if (size < 14 || size > IMAGE_MAX) return
22 const b = bytesAt(base64, 0, Math.min(size, 32))
23 const end = bytesAt(base64, Math.max(0, size - 12), Math.min(12, size))
24 const ascii = (a: number[]) => a.map(n => String.fromCharCode(n)).join('')
25 const be = (at: number) => b.slice(at, at + 4).reduce((v, n) => v * 256 + n, 0)
26 const le = (at: number) => b.slice(at, at + 4).reverse().reduce((v, n) => v * 256 + n, 0)
27 let type: string | undefined
28 if (size >= 45 && ascii(b.slice(0, 8)) === '\x89PNG\r\n\x1a\n' && be(8) === 13 && ascii(b.slice(12, 16)) === 'IHDR' && be(16) > 0 && be(20) > 0 && ascii(end.slice(4, 8)) === 'IEND') type = 'image/png'
29 else if (b[0] === 255 && b[1] === 216 && b[2] === 255 && end.at(-2) === 255 && end.at(-1) === 217) type = 'image/jpeg'
30 else if (['GIF87a', 'GIF89a'].includes(ascii(b.slice(0, 6))) && (b[6]! | b[7]! << 8) > 0 && (b[8]! | b[9]! << 8) > 0 && end.at(-1) === 59) type = 'image/gif'
31 else if (size >= 20 && ascii(b.slice(0, 4)) === 'RIFF' && le(4) + 8 === size && ascii(b.slice(8, 12)) === 'WEBP' && ['VP8 ', 'VP8L', 'VP8X'].includes(ascii(b.slice(12, 16)))) type = 'image/webp'
32 return type ? { base64, type, originalSize: size } : undefined
33}
34
35/** A later file preview, acquired through fs.*, never Claude's Read executor. */
36export async function captureImage($: EngineInterface, path: string): Promise<{ action?: Action; reason?: string }> {
37 try {
38 const before = await $.fs.stat(path, { resolve: true })
39 if (before.kind !== 'file' || before.size > IMAGE_MAX) return { reason: 'image is not a regular file or exceeds the 4 MiB preview bound' }
40 const { base64 } = await $.fs.read(path, { as: 'bytes' })
41 const after = await $.fs.stat(path, { resolve: true })
42 const file = imageFile(base64)
43 if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.realPath !== after.realPath || after.kind !== 'file' || file && file.originalSize !== after.size) return { reason: 'image changed during preview capture' }
44 if (!file) return { reason: 'unsupported or invalid image bytes (PNG/JPEG/GIF/WebP required)' }
45 return { action: { tool: 'Read', input: { file_path: path }, result: { type: 'image', file } } }
46 } catch (err) {
47 return { reason: `image preview unavailable: ${err instanceof Error ? err.message : String(err)}` }
48 }
49}
50
51export function validImage(a: Action): boolean {
52 const r: any = a.result, i = a.input
53 if (a.error || a.tool !== 'Read' || Object.keys(i).length !== 1 || typeof i.file_path !== 'string' || !i.file_path.startsWith('/')) return false
54 if (r?.type !== 'image' || Object.keys(r).length !== 2 || !r.file || Object.keys(r.file).some(k => !['base64', 'type', 'originalSize'].includes(k))) return false
55 const file = imageFile(r.file.base64)
56 return file !== undefined && file.type === r.file.type && file.originalSize === r.file.originalSize
57}
58
59export function generationAction(item: Record<string, any>): Action {
60 const args = Object.fromEntries(['status', 'revisedPrompt', 'result', 'savedPath', 'transparentBackground', 'failure'].filter(k => k in item).map(k => [k, item[k]]))
61 return { tool: 'codex_tool', input: { server: 'codex', name: 'imageGeneration', arguments: args },
62 result: [{ type: 'text', text: `Codex image generation\n${JSON.stringify(args, null, 2)}` }],
63 ...(['failed', 'declined'].includes(item.status) || item.failure ? { error: `Codex image generation ${item.status ?? 'failed'}: ${JSON.stringify(args)}` } : {}) }
64}
65
66export function findingsAction(item: Record<string, any>): Action | undefined {
67 const findings = item.findings
68 if (!Array.isArray(findings) || !findings.every(f => f && typeof f === 'object' && !Array.isArray(f) &&
69 Object.keys(f).every(k => ['file', 'summary', 'failure_scenario', 'line', 'short_summary', 'category', 'verdict', 'outcome'].includes(k)) &&
70 ['file', 'summary', 'failure_scenario'].every(k => typeof f[k] === 'string') &&
71 (f.line === undefined || Number.isInteger(f.line) && f.line >= 1) &&
72 ['short_summary', 'category'].every(k => f[k] === undefined || typeof f[k] === 'string') &&
73 (f.verdict === undefined || ['CONFIRMED', 'PLAUSIBLE'].includes(f.verdict)) &&
74 (f.outcome === undefined || ['fixed', 'skipped', 'no_change_needed'].includes(f.outcome)))) return
75 if (item.level !== undefined && !['low', 'medium', 'high', 'xhigh', 'max'].includes(item.level)) return
76 const input = { findings, ...(item.level === undefined ? {} : { level: item.level }) }
77 return { tool: 'ReportFindings', input, result: { count: findings.length, ...input } }
78}
79
80/** Markdown fallback must display even tags, fences and leading override lines. */
81export function literalReceipt(text: string): string {
82 if (!/[<`*_#\[\]~\\]|^(?:[ >|+-]|\d+\.)/m.test(text)) return `Coordinator message:\n\n${text}`
83 const runs = text.match(/`+/g) ?? []
84 const fence = '`'.repeat(Math.max(3, ...runs.map(s => s.length + 1)))
85 return `Coordinator message:\n\n${fence}text\n${text}\n${fence}`
86}
87hooks/questions.mjs 42 lines1// Shared by the hook and sidecar so a UI selection cannot widen the RPC contract.
2const object = v => v !== null && typeof v === 'object' && !Array.isArray(v)
3const only = (v, keys) => Object.keys(v).every(k => keys.includes(k))
4export const QUESTION_METHOD = 'item/tool/requestUserInput'
5
6export function choiceQuestions(params) {
7 const qs = params?.questions
8 if (!object(params) || !['threadId', 'turnId', 'itemId'].every(k => typeof params[k] === 'string' && params[k] !== '') || typeof params.isBlocking !== 'boolean') return false
9 return Array.isArray(qs) && qs.length >= 1 && qs.length <= 4 &&
10 new Set(qs.map(q => q?.id)).size === qs.length && new Set(qs.map(q => q?.question)).size === qs.length && qs.every(q =>
11 object(q) && typeof q.id === 'string' && q.id !== '' && typeof q.question === 'string' && q.question !== '' &&
12 typeof q.header === 'string' && q.header.length >= 1 && q.header.length <= 12 && q.isSecret === false && q.isOther === false &&
13 Array.isArray(q.options) && q.options.length >= 2 && q.options.length <= 4 && new Set(q.options.map(o => o?.label)).size === q.options.length &&
14 q.options.every(o => object(o) && typeof o.label === 'string' && o.label !== '' && typeof o.description === 'string'))
15}
16
17export function choiceResponse(params, result) {
18 return choiceQuestions(params) && object(result) && only(result, ['answers']) && object(result.answers) &&
19 Object.keys(result.answers).length === params.questions.length && params.questions.every(q => {
20 const a = Object.hasOwn(result.answers, q.id) ? result.answers[q.id] : undefined
21 return object(a) && only(a, ['answers']) && Array.isArray(a.answers) && a.answers.length === 1 && q.options.some(o => o.label === a.answers[0])
22 })
23}
24
25/** Only an acknowledged Codex RPC answer can supply a native reported-answer row. */
26export function answeredQuestion(params, response) {
27 if (!choiceResponse(params, response)) return
28 const questions = params.questions.map(q => ({ question: q.question, header: q.header, options: q.options.map(o => ({ label: o.label, description: o.description })), multiSelect: false }))
29 const answers = Object.fromEntries(params.questions.map(q => [q.question, response.answers[q.id].answers[0]]))
30 return { tool: 'AskUserQuestion', input: { questions, answers }, result: { questions, answers } }
31}
32
33export function validAnsweredQuestion(action) {
34 const i = action.input, r = action.result
35 if (action.tool !== 'AskUserQuestion' || !object(i) || !only(i, ['questions', 'answers']) || !object(i.answers) || !Array.isArray(i.questions)) return false
36 const params = { threadId: 'held', turnId: 'held', itemId: 'held', isBlocking: true,
37 questions: i.questions.map((q, n) => ({ ...q, id: String(n), isOther: false, isSecret: false })) }
38 if (!choiceQuestions(params) || i.questions.some(q => !only(q, ['question', 'header', 'options', 'multiSelect']) || q.multiSelect !== false || q.options.some(o => !only(o, ['label', 'description'])))) return false
39 if (Object.keys(i.answers).length !== i.questions.length || !i.questions.every(q => Object.hasOwn(i.answers, q.question) && q.options.some(o => o.label === i.answers[q.question]))) return false
40 return object(r) && only(r, ['questions', 'answers']) && JSON.stringify(r) === JSON.stringify({ questions: i.questions, answers: i.answers })
41}
42hooks/steer.ts 57 lines1import type { EngineInterface } from 'claude-code'
2import { messageStatus } from './steer-control-text.mjs'
3export { messageStatus }
4
5import type { CodexModMessageOutcome as MessageOutcome } from './steer-contract.d.ts'
6export type { CodexModMessageOutcome as MessageOutcome } from './steer-contract.d.ts'
7type Channel = { channel: 'unix' | 'file'; generation?: string; runId: string; socketPath?: string }
8const channels = new Map<string, Promise<Channel>>()
9
10/** Negotiate before submitting anything. Only a failed health probe permits
11 * file fallback; a failed POST has an uncertain outcome and cannot switch. */
12async function channel($: EngineInterface, control: string): Promise<Channel> {
13 let pending = channels.get(control)
14 if (!pending) {
15 pending = (async () => {
16 try {
17 const d = JSON.parse(String(await $.fs.read(`${control}/control.json`)))
18 if (d.version !== 2 || d.runId !== control || d.channel !== 'unix' || typeof d.socketPath !== 'string') throw new Error('file controls')
19 const r = await $.http.fetch('http://codex-mod/health', { socketPath: d.socketPath })
20 const h = JSON.parse(r.text)
21 if (!r.ok || h.version !== 2 || h.generation !== d.generation || h.runId !== control) throw new Error('health mismatch')
22 $.ui.log('codex-mod: steer v2 channel=unix protocol=2', { to: 'debug' })
23 return d as Channel
24 } catch {
25 $.ui.log('codex-mod: steer v2 channel=file (Unix health unavailable)', { to: 'debug' })
26 return { channel: 'file' as const, runId: control }
27 }
28 })()
29 channels.set(control, pending)
30 }
31 return pending
32}
33export async function controlRequest($: EngineInterface, control: string, path: '/messages' | '/interrupt', body: Record<string, unknown>, file: string): Promise<MessageOutcome | undefined> {
34 const c = await channel($, control)
35 if (c.channel === 'file') {
36 // A partial file is retried by the runner; its journal deduplicates replays.
37 await $.fs.write(`${control}/${file}`, JSON.stringify(body))
38 return
39 }
40 const response = await $.http.fetch(`http://codex-mod${path}`, { socketPath: c.socketPath, method: 'POST',
41 headers: { 'content-type': 'application/json', 'x-codex-generation': c.generation! }, body: JSON.stringify({ ...body, runId: control }) })
42 if (!response.ok) throw new Error(`control request refused (${response.status}); no fallback`)
43 if (path === '/messages') return JSON.parse(response.text)
44}
45export async function queryOutcome($: EngineInterface, control: string, messageId: string): Promise<MessageOutcome | undefined> {
46 const c = await channel($, control)
47 if (c.channel !== 'unix') return
48 const r = await $.http.fetch(`http://codex-mod/messages/${encodeURIComponent(messageId)}`, { socketPath: c.socketPath,
49 headers: { 'x-codex-generation': c.generation! } })
50 return r.ok ? JSON.parse(r.text) : undefined
51}
52export async function publishOutcome($: EngineInterface, outcome: MessageOutcome): Promise<void> {
53 await $.state.set({ plugin: 'codex-mod', key: 'messageStatus', id: outcome.messageId }, outcome).catch(() => undefined)
54 $.ui.invalidate('ui.render')
55 $.ui.log(`codex-mod: steer v2 message=${outcome.messageId} status=${outcome.status}${outcome.acceptedTurnId ? ` turn=${outcome.acceptedTurnId}` : ''}`, { to: 'debug' })
56}
57hooks/store-state.ts 187 lines1import type { EngineInterface } from 'claude-code'
2import { fingerprint } from './fingerprint.ts'
3
4// The host limits the whole plugin store to 4 Mi characters. Payloads belong
5// in the private run cache; store entries are only bounded recovery metadata.
6export const RECORD_BUDGET = 32 * 1024
7export const FIELD_BUDGET = 2048
8export const DEDUP_LIMIT = 128
9// Even four-byte UTF-8 text and JSON escapes stay below the host's 4 MiB I/O cap.
10export const CACHE_CHUNK = 256 * 1024
11type State = Record<string, any>
12const size = (v: unknown) => JSON.stringify(v)?.length ?? 0
13const payloads = new Set(['wire', 'display', 'followUps'])
14const sameFields = (a: State, b: State) => Object.keys(a).every(k => JSON.stringify(a[k]) === JSON.stringify(b[k]))
15const pendingMigrations = new Map<string, State>()
16const writing = new Map<string, Promise<boolean>>()
17
18async function orderedWrite(key: string, operation: () => Promise<boolean>): Promise<boolean> {
19 const previous = writing.get(key)
20 const current = (async () => {
21 await previous
22 return operation()
23 })()
24 writing.set(key, current)
25 try {
26 return await current
27 } finally {
28 if (writing.get(key) === current) writing.delete(key)
29 }
30}
31
32export function compactStoreState(value: State): State {
33 const out: State = {}
34 for (const [key, value0] of Object.entries(value)) {
35 if (key === 'cachedState' || payloads.has(key)) continue
36 let v = value0
37 if (key === 'coordinator') {
38 v = { received: (v?.received ?? []).slice(-DEDUP_LIMIT).filter((id: unknown) => typeof id === 'string' && id.length <= 128), pending: [] }
39 while (size(v) > FIELD_BUDGET) v.received.shift()
40 } else if (key === 'playback') v = Object.fromEntries(['segment', 'cursor', 'prefix', 'actions', 'usage', 'usageCanonical', 'usagePartial', 'turnId'].filter(k => v?.[k] !== undefined).map(k => [k, v[k]]))
41 else if (key === 'mirror') v = compactStoreState(v)
42 else if (Array.isArray(v)) v = v.slice(-DEDUP_LIMIT)
43 if (v !== undefined && size(v) <= FIELD_BUDGET) out[key] = v
44 }
45 for (const key of ['lastTask', 'launchKey', 'capacityRetryKey']) {
46 if (typeof value[key] === 'string') out[`${key}Fingerprint`] = fingerprint(value[key])
47 }
48 if (typeof value.guardTrip?.key === 'string') {
49 out.guardTrip ??= { reason: value.guardTrip.reason }
50 out.guardTripKeyFingerprint = fingerprint(value.guardTrip.key)
51 }
52 // A total guard is necessary even when every individual field is small.
53 // Required identity/guard fields go first; optional snapshots are expendable.
54 const required = ['executionFingerprint', 'historyArchived', 'historyAmbiguous', 'model', 'effort', 'sandbox', 'agentName', 'thread', 'updatedAt', 'version', 'session', 'parentHost', 'parentThread', 'control', 'generation', 'token', 'depth', 'launch', 'state', 'host', 'handedBack', 'handback', 'handbackLock', 'userCount', 'launchesSinceNew', 'runSeq', 'lastTaskFingerprint', 'launchKeyFingerprint', 'capacityRetryKeyFingerprint', 'guardTripKeyFingerprint', 'guardTrip', 'launches', 'recoveryUnavailable']
55 const bounded: State = {}
56 for (const key of [...required, ...Object.keys(out).filter(k => !required.includes(k))]) {
57 if (out[key] !== undefined && size({ ...bounded, [key]: out[key], cachedState: true, recoveryUnavailable: true, cacheFingerprint: { length: 0, sha256: '0'.repeat(64) } }) <= RECORD_BUDGET) bounded[key] = out[key]
58 }
59 return bounded
60}
61
62/** Publish the manifest last. A partial chunk write cannot become a checkpoint. */
63export async function writeCachePayload($: EngineInterface, path: string, body: string): Promise<void> {
64 if (body.length <= CACHE_CHUNK) return $.fs.write(path, body)
65 const identity = fingerprint(body)
66 const chunks = Math.ceil(body.length / CACHE_CHUNK)
67 for (let i = 0; i < chunks; i++) {
68 // JSON strings preserve a surrogate pair split at a chunk boundary.
69 await $.fs.write(`${path}.${i}`, JSON.stringify(body.slice(i * CACHE_CHUNK, (i + 1) * CACHE_CHUNK)))
70 }
71 await $.fs.write(path, JSON.stringify({ chunked: 1, chunks, ...identity }))
72}
73
74export async function readCachePayload($: EngineInterface, path: string): Promise<any> {
75 const manifest = JSON.parse(String(await $.fs.read(path)))
76 if (manifest?.chunked !== 1) return manifest
77 if (!Number.isSafeInteger(manifest.chunks) || manifest.chunks < 1 || manifest.chunks !== Math.ceil(manifest.length / CACHE_CHUNK)) throw new Error('invalid state manifest')
78 const parts: string[] = []
79 for (let i = 0; i < manifest.chunks; i++) {
80 const part = JSON.parse(String(await $.fs.read(`${path}.${i}`)))
81 if (typeof part !== 'string' || part.length > CACHE_CHUNK) throw new Error('invalid state chunk')
82 parts.push(part)
83 }
84 const body = parts.join('')
85 const identity = fingerprint(body)
86 if (identity.length !== manifest.length || identity.sha256 !== manifest.sha256) throw new Error('state chunks do not match checkpoint')
87 return JSON.parse(body)
88}
89
90async function statePath($: EngineInterface, key: string): Promise<string> {
91 const home = (await $.env.get('HOME')) ?? ''
92 const cache = (await $.env.get('XDG_CACHE_HOME')) || `${home}/.cache`
93 return `${cache.replace(/\/$/, '')}/codex-mod/runs/state-${encodeURIComponent(key)}/record.json`
94}
95
96/** Never let persistence interrupt execution or the final handback. */
97export async function writeStoreState($: EngineInterface, key: string, value: State, legacySize = 0): Promise<boolean> {
98 // A receipt and a step can checkpoint the same agent concurrently. Keep
99 // the recovery file and its store metadata in the same write order.
100 return orderedWrite(key, () => persistState($, key, value, legacySize))
101}
102
103async function persistState($: EngineInterface, key: string, value: State, legacySize: number): Promise<boolean> {
104 try {
105 if (value.coordinator) {
106 value.coordinator.received = value.coordinator.received.slice(-DEDUP_LIMIT)
107 if (value.coordinator.rows) value.coordinator.rows = Object.fromEntries(Object.entries(value.coordinator.rows).slice(-DEDUP_LIMIT))
108 }
109 const compact = compactStoreState(value)
110 const snapshot = { ...value }
111 delete snapshot.cachedState
112 delete snapshot.cacheFingerprint
113 const body = JSON.stringify(snapshot)
114 let cached = false
115 const needsCache = !sameFields(snapshot, compact)
116 if (needsCache) {
117 $.ui.log(`codex-mod: compacting store record ${key} (${body.length} -> ${size(compact)} characters; budget ${RECORD_BUDGET})`, { to: 'debug' })
118 try {
119 const path = await statePath($, key)
120 const dir = path.slice(0, path.lastIndexOf('/'))
121 await $.process.run(['mkdir', '-p', '-m', '700', dir], { timeoutMs: 10000 })
122 await writeCachePayload($, path, body)
123 compact.cachedState = true
124 compact.cacheFingerprint = fingerprint(body)
125 cached = true
126 } catch (err) {
127 $.ui.log(`codex-mod: state cache write failed: ${String(err)}`, { to: 'debug' })
128 }
129 }
130 if (needsCache && !cached) compact.recoveryUnavailable = true
131 if (size(compact) > RECORD_BUDGET) throw new Error('compact record exceeds budget')
132 try {
133 await $.store.set(key, compact)
134 } catch (err) {
135 // An already oversized store may reject even a shrinking replacement.
136 // Only remove legacy payloads after their complete recovery file exists.
137 if (!cached || legacySize <= size(compact)) throw err
138 await $.store.delete(key)
139 pendingMigrations.set(key, compact)
140 await $.store.set(key, compact)
141 }
142 pendingMigrations.delete(key)
143 return !needsCache || cached
144 } catch (err) {
145 $.ui.log(`codex-mod: store write failed for ${key}: ${String(err)}`, { to: 'debug' })
146 return false
147 }
148}
149
150/** Retry removed legacy entries after the rest of pruning has freed space. */
151export async function flushStoreMigrations($: EngineInterface): Promise<void> {
152 for (const key of pendingMigrations.keys()) {
153 await orderedWrite(key, async () => {
154 try {
155 const compact = pendingMigrations.get(key)
156 if (!compact) return true
157 if (size(compact) > RECORD_BUDGET) throw new Error('compact record exceeds budget')
158 await $.store.set(key, compact)
159 pendingMigrations.delete(key)
160 return true
161 } catch (err) {
162 $.ui.log(`codex-mod: store migration retry failed for ${key}: ${String(err)}`, { to: 'debug' })
163 return false
164 }
165 })
166 }
167}
168
169/** Restore data only, never guard capabilities or executable held answers. */
170export async function readStoreState($: EngineInterface, key: string, value: State): Promise<State> {
171 if (value.cachedState) {
172 try {
173 const saved = await readCachePayload($, await statePath($, key))
174 if (value.cacheFingerprint && JSON.stringify(fingerprint(JSON.stringify(saved))) !== JSON.stringify(value.cacheFingerprint)) throw new Error('state checkpoint fingerprint does not match store metadata')
175 if (saved && typeof saved === 'object' && saved.updatedAt === value.updatedAt) return saved
176 throw new Error('state checkpoint does not match store metadata')
177 } catch (err) {
178 $.ui.log(`codex-mod: state cache read failed for ${key}: ${String(err)}`, { to: 'debug' })
179 return { ...value, recoveryUnavailable: true }
180 }
181 } else if (JSON.stringify(value) !== JSON.stringify(compactStoreState(value))) {
182 await writeStoreState($, key, value, size(value))
183 }
184 if (!value.lastTask && !value.lastTaskFingerprint && !value.launchKey && !value.launchKeyFingerprint && value.userCount > 0) return { ...value, recoveryUnavailable: true }
185 return value
186}
187hooks/fingerprint.ts 80 lines1// Hooks run without Node's crypto module. Hash UTF-8 incrementally so even a
2// large recovery payload needs only one compression block of scratch space.
3const K = [
4 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
5 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
6 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
7 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
8 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
9 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
10 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
11 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
12]
13const rotate = (v: number, n: number) => (v >>> n) | (v << (32 - n))
14export type Fingerprint = { length: number; sha256: string }
15
16export function fingerprint(text: string): Fingerprint {
17 const h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
18 const words = new Array<number>(64).fill(0)
19 let used = 0
20 let bytes = 0
21 const put = (byte: number) => {
22 words[used >>> 2] |= byte << (24 - (used % 4) * 8)
23 if (++used !== 64) return
24 for (let i = 16; i < 64; i++) {
25 const x = words[i - 15]!, y = words[i - 2]!
26 words[i] = (words[i - 16]! + (rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3)) + words[i - 7]! + (rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10))) | 0
27 }
28 let [a, b, c, d, e, f, g, v] = h as [number, number, number, number, number, number, number, number]
29 for (let i = 0; i < 64; i++) {
30 const t = (v + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + K[i]! + words[i]!) | 0
31 const u = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
32 v = g
33 g = f
34 f = e
35 e = (d + t) | 0
36 d = c
37 c = b
38 b = a
39 a = (t + u) | 0
40 }
41 for (const [i, n] of [a, b, c, d, e, f, g, v].entries()) h[i] = (h[i]! + n) | 0
42 words.fill(0)
43 used = 0
44 }
45 for (const char of text) {
46 let c = char.codePointAt(0)!
47 if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd
48 if (c < 0x80) {
49 put(c)
50 bytes++
51 } else if (c < 0x800) {
52 put(0xc0 | (c >>> 6))
53 put(0x80 | (c & 63))
54 bytes += 2
55 } else if (c < 0x10000) {
56 put(0xe0 | (c >>> 12))
57 put(0x80 | ((c >>> 6) & 63))
58 put(0x80 | (c & 63))
59 bytes += 3
60 } else {
61 put(0xf0 | (c >>> 18))
62 put(0x80 | ((c >>> 12) & 63))
63 put(0x80 | ((c >>> 6) & 63))
64 put(0x80 | (c & 63))
65 bytes += 4
66 }
67 }
68 put(0x80)
69 while (used !== 56) put(0)
70 const high = Math.floor(bytes / 0x20000000), low = (bytes * 8) >>> 0
71 for (const n of [high, low]) for (const shift of [24, 16, 8, 0]) put((n >>> shift) & 255)
72 return { length: text.length, sha256: h.map(n => (n >>> 0).toString(16).padStart(8, '0')).join('') }
73}
74
75export function matchesIdentity(record: Record<string, any>, field: string, text: string): boolean {
76 if (typeof record[field] === 'string') return record[field] === text
77 const saved = record[`${field}Fingerprint`]
78 return saved?.length === text.length && saved.sha256 === fingerprint(text).sha256
79}
80hooks/child-journal.mjs 42 lines1// Shared names and bounded readers; this module also runs in the hooks worker.
2export const JOURNAL_BYTES = 1024 * 1024
3export const journalPath = (control, thread, segment = 0) => `${control}/events-${thread}${segment ? `-${segment}` : ''}.jsonl`
4export const journalIndex = (control, thread) => `${control}/events-${thread}.index.json`
5const missing = err => /ENOENT|not found|no such file/i.test(String(err))
6
7export async function journalRead($, path, optional = false) {
8 for (let attempt = 0; ; attempt++) {
9 try {
10 if (optional && !await $.fs.exists(path)) return undefined
11 const value = await $.fs.read(path)
12 if (typeof value !== 'string') throw new Error('expected journal text')
13 return value
14 } catch (err) {
15 if (optional && missing(err)) return undefined
16 if (attempt === 2) throw new Error(`child journal read failed after 3 attempts: ${String(err)}; no relaunch`)
17 await $.clock.sleep(50 * (attempt + 1))
18 }
19 }
20}
21
22/** Read one complete line across immutable segments, keeping only one segment
23 * resident per read. A failed read never changes the caller's committed cursor. */
24export async function journalLine($, control, thread, position) {
25 const indexText = await journalRead($, journalIndex(control, thread), true)
26 const index = indexText === undefined ? { segment: 0 } : JSON.parse(indexText)
27 if (!Number.isSafeInteger(index.segment) || index.segment < position.segment) throw new Error('child journal truncated or replaced; no relaunch')
28 let { segment, cursor, prefix } = position
29 const parts = []
30 for (;;) {
31 const text = await journalRead($, journalPath(control, thread, segment))
32 if (text.length < cursor || cursor && text.slice(Math.max(0, cursor - 160), cursor) !== prefix) throw new Error('child journal truncated or replaced; no relaunch')
33 const nl = text.indexOf('\n', cursor)
34 if (nl >= 0) return { line: [...parts, text.slice(cursor, nl)].join(''), segment, cursor: nl + 1, prefix: text.slice(Math.max(0, nl + 1 - 160), nl + 1) }
35 parts.push(text.slice(cursor))
36 if (segment === index.segment) return undefined
37 segment++
38 cursor = 0
39 prefix = ''
40 }
41}
42