SLOPSHOPPER

codex-mod

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

rowsguardtoolprocessnetworktimer
★ 1v1.2.1MITupdated 2026-10-21diegoaleyvag/codex-mod
A shopper browsing a rack in a slop shop
README

codex-mod

CI License: MIT Version 1.2.1

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.

Codex agent steered mid-turn in Claude Code Desktop: the agent panel shows its commands, the "↪ steer delivered to Codex mid-turn" note and the footer with app-server · 1 steer

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.

What's new in 1.2.1

  • Explicit working directories, registered-worktree validation and owner grants for network, writable roots, sandbox and approval modes, pinned across resumes.
  • Opt-in Codex automatic review, manual controls and delegation to the immediate Claude parent. Experimental controls and their host limits are listed under Execution policy.
  • More useful deterministic command titles and optional completion archiving that preserves history and same-thread follow-ups.
  • CI coverage for runner, approval, execution-policy and recovery helpers.

Included from 1.2

  • More faithful action rows: reads, searches, file changes, web results and plans use native rows when source evidence and host admission permit them, with full-text fallbacks and conservative compound-command labels.
  • Model and effort selection: choose a registered model type or declare project roles; titles identify the executing Codex model and effort.
  • Steering and child lifecycle: correlated delivery outcomes, existing-child observation without rerunning its work, bounded retries and honest fallbacks.
  • Recovery and accounting: bounded presentation caches, segmented child journals, exact launch identities, direct/tree usage and one same-thread capacity fallback when configured.

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.

Upgrading from 1.1

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.

How it works

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
  • Agent types. Registered at session start from Codex's own model list (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.
  • Native rows. Each finished Codex command or file change is drawn as a 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.
  • Handback. The final answer is delivered once through SubagentHandback, followed by a footer line.
  • Resume. Each agent keeps its Codex thread, so follow-ups continue the same conversation.
  • Transport. By default Codex runs through codex exec --json. The opt-in app-server transport adds mid-turn messages and stop (see Transports).

What Codex work looks like

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.

Requirements

  • Claude Code with plugin hooks-module (mod) support. Verified on Claude Code Desktop, CLI 2.1.299 to 2.1.302.
  • Auto mode, so the host offers SubagentHandback. Outside it, the answer is delivered as plain text after one extra step.
  • The Codex CLI, installed and logged in (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.

Install

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

Usage

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.

Choosing model and effort

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.

Roles per project

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.

  • The file must be an object with a roles object. Each role requires
Source 21 files
hooks/codex.ts 4995 lines
1// 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 lines
1// 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}
122
hooks/exploration.ts 295 lines
1import 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}
295
hooks/patches.ts 185 lines
1import 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}
185
hooks/web-tools.ts 107 lines
1import 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}
107
hooks/plans-children.ts 169 lines
1import 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}
169
hooks/images-modes.ts 87 lines
1import 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}
87
hooks/questions.mjs 42 lines
1// 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}
42
hooks/steer.ts 57 lines
1import 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}
57
hooks/store-state.ts 187 lines
1import 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}
187
hooks/fingerprint.ts 80 lines
1// 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}
80
hooks/child-journal.mjs 42 lines
1// 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