Delegates subagents to Codex: Codex roles Claude can pick (advisor, reviewer, implementer) plus chosen subagent types, in the background or waited on, with…

Delegates chosen subagents from Claude Code to Codex, gets independent Codex reviews of your diff, and measures every run so you route by evidence rather than by assumption.
duet:codex-advisor (a second opinion; Claude waits for it), duet:codex-reviewer (an adversarial review that arrives in the background) and, under workspace-write, duet:codex-implementer (makes a scoped change; Claude waits) to Claude's agent list. They always run on Codex. Claude-side roles stay your own .claude/agents files./config → duet → Subagent types sent to Codex. When Claude spawns one of those (a built-in like general-purpose, or a custom agent from .claude/agents/), Codex does the work, with a footer: [duet d1 · codex · sandbox read-only · base 45798a0 · read-only run: no files changed · one-shot run: start a new duet task to follow up]. Named, isolated and nested calls stay on Claude (Claude names an agent when it plans to message it, and a Codex run can't be messaged); /duet says so in its routes line. Every other type stays on Claude.codexRunMode overrides it. A run that can edit always waits, so Codex and Claude never edit at the same time..claude/agents/ from the session folder up to the repository root, nearest first, then in ~/.claude/agents/, in each folder and all its subfolders, and matches on the name field in the file's frontmatter, not on the file name. As for Claude, the repository root is the nearest folder holding .git, even one git can't read; with none, the search goes up to the filesystem root. A file with no name or no description, or a name that starts with -, has a : or is over 256 characters, is skipped, as Claude skips it. A name or description may start on the next, indented line. Two files in one folder that share a name and differ on whether they can edit run read-only. Symbolic links are followed, as Claude follows them, and a folder reached twice is searched once. At most 8 folders deep and 500 .md files per .claude/agents/ folder, its subfolders included, are read. An agent past these limits counts as not found, though Claude, which read 12 folders deep in a probe, may use it. So a search cut short runs the agent read-only, and its answer says the search was cut short, naming the limit; an agent that could otherwise have edited still waits for that answer. Agents from managed settings, --agents or an --add-dir folder aren't reached: on their own they count as not found and run read-only, but if a project or user file has the same name, duet uses that file, though Claude runs the other one. A plugin's agent (<plugin>:<name>) can't be mistaken this way, since a name with : is skipped. The matched file's body goes to Codex as role instructions. If its tools: (minus disallowedTools) grant no edit tool, Codex runs read-only regardless of the setting. Both lists may be one line or a YAML block of - Name lines; Write(src/**) counts as all of Write. A form duet doesn't read (a > or | block, a [ list that wraps, a value continued on the next line) also runs read-only. An agent duet can't find a definition for (a built-in like Explore or Plan, a plugin's agent) also runs read-only; only general-purpose edits without one./duet review sends Codex the exact diff, never the implementer's summary. The review runs on Codex's own review instructions, and its verdict line (with none, its first line) lands in the pane. Duet never applies fixes or chains another round. The command answers started only after consent and the concurrency limit allow it; otherwise it answers Codex review not started: <reason>./duet stats compares them. A background or teammate Claude subagent is measured when its first turn ends. D U E T 1 running · 2 done · 2 failed
──────────────────────────────────────────────────────────────────────────
▸ d5 codex gpt-5-codex code-explorer running 1m12s 38k tok
"Find every caller of parseConfig and list risky ones"
spec 3fa9c21 · base 45798a0 · sandbox read-only
d4 codex gpt-5-codex review --base main done 2m40s 61k tok
verdict: changes requested (1 high, 2 medium, 0 low)
d2 codex gpt-5-codex Explore error 0m04s – tok
✕ codex: not signed in. Run `codex login`, then retry.
──────────────────────────────────────────────────────────────────────────
routes codex ← code-explorer · named, isolated, nested stay Claude
roles advisor waits · reviewer bg · others bg read-only · max 2
[ Review diff ] [ Stats ] [ Next ] [ Prev ] [ Close ]
/duet demo
/duet: opens the pane, or closes it when it is shown. When it is open as a tab behind another pane, it comes to the front with the keyboard instead of closing; Esc returns the keyboard to the prompt. Keys once the pane has the keyboard (ctrl+x tab, again for the next tab): r review, s stats, j/k move, c close/duet demo: one task in each state until the turn ends; nothing runs/duet review [--base <branch> | --commit <sha>]: an independent read-only Codex review (default: uncommitted changes)/duet show <id>: a review's full text/duet stats: runs, success rate, median time and tokens per provider and subagent type/duet routes: what goes where, what stays on Claude, each role's wait/background mode, and the sandbox/duet config: opens a config pane listing every option with its value. j/k move the highlight, e changes the highlighted option (sandbox, consent and scope step to their next choice; the others open a text box, Enter sets it), s saves, u reverts unsaved edits, c closes. Edited rows are marked • until saved; after s the pane stays open (or comes back after duet reloads with the new settings) on the same row. On the model row, e opens Codex's live model list (codex debug models): 1–9 pick a model, 0 clears it back to Codex's default, and either closes the list; t types a name instead, r refreshes, and a name Codex doesn't list gets a "did you mean" warning. /duet config list prints the options as text, /duet config models lists the models Codex offers, and /duet config <field> <value> changes one directly (aliases agents, sandbox, model, max, timeout, consent, mode, scope, paths; "" clears a text field). Use this when /plugin no longer shows options that already have a valueReaching the pane when other panes are open: ctrl+x tab shows the first tab and gives it the keyboard, each press after moves one tab right, and after the last tab the keyboard goes back to the prompt. Esc hands it back from any tab. A letter that is one of the pane's keys acts at once. To cycle without going through the first tab, bind pane:next and pane:previous (no default key) in ~/.claude/keybindings.json; it works while a pane holds the keyboard:
{"bindings":[{"context":"Pane","bindings":{"ctrl+x n":"pane:next","ctrl+x p":"pane:previous"}}]}
Needs the Codex CLI on PATH, signed in once with codex login. Duet uses Codex's own sign-in and usage limits. Codex runs with Claude Code's credential and provider variables blanked; the full list is BLANKED_ENV in hooks/duet-logic.ts. It covers ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS, CLAUDE_CODE_OAUTH_TOKEN and its refresh token, CLAUDE_CODE_SESSION_ACCESS_TOKEN, the mTLS key passphrase CLAUDE_CODE_CLIENT_KEY_PASSPHRASE, the session's CLAUDE_CODE_MESSAGING_TOKEN and CLAUDE_CODE_MESSAGING_SOCKET (so Codex can't post into your session's inbox), and ANTHROPIC_IDENTITY_TOKEN (workload identity federation); the Bedrock, Bedrock Mantle, Claude Platform on AWS, Vertex and Foundry settings and keys; the AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_BEARER_TOKEN_BEDROCK, and the container provider's AWS_CONTAINER_AUTHORIZATION_TOKEN); and the Azure SDK's credential variables (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_CLIENT_CERTIFICATE_PASSWORD, AZURE_PASSWORD) and managed identity's IDENTITY_HEADER. Paths and profile names (CLAUDE_CONFIG_DIR, ANTHROPIC_PROFILE, AWS_PROFILE, GOOGLE_APPLICATION_CREDENTIALS and the like) are not blanked, the messaging socket apart: a mod can only set a variable to empty, not unset it, and an empty path changes what a tool does. If Codex itself uses Amazon Bedrock, sign it in with an AWS profile: AWS_PROFILE reaches it, but the AWS key variables and AWS_BEARER_TOKEN_BEDROCK reach it empty. Other variables in your environment pass through, including OPENAI_API_KEY, which Codex needs when you sign in with an API key. If Claude Code runs with CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1, it removes OPENAI_API_KEY too, so store the key from a shell where it is set, with printenv OPENAI_API_KEY | codex login --with-api-key (the option reads the key from stdin), or sign in with codex login instead. Blanking hides variables, not files: Codex can still read a credentials file such as ~/.aws/credentials, as the next paragraph says. Nor does it hide Claude Code's own environment: on Linux, any process running as you, Codex included, can read it from /proc/<pid>/environ. So blanking keeps Codex from picking up the credentials held in those variables by accident, but it is not a barrier against Codex looking for them. Nor does it cover a login that Codex's tools find on their own: an aws or gcloud command Codex runs still signs in with Claude Code's AWS profile (the one AWS_PROFILE names, or [default] in ~/.aws/credentials) or Google credentials (the key file GOOGLE_APPLICATION_CREDENTIALS names, or gcloud's application_default_credentials.json) without being asked to, as it would for any program you run.
Codex's sandbox limits writes, not reads. In either mode Codex can read any file your account can read and send it to OpenAI; under workspace-write it can also write in the session folder and in /tmp, where Claude Code keeps its own working files (measured with Codex 0.162.1 on Linux; see SPEC, Change 0.2.4). The handoff asks Codex to stay in scope, but that's a request to the model, not enforcement. A routed agent's instructions come from a .claude/agents/ folder in the checkout or above it, or from wherever a symbolic link there points, so route only agents you trust, in repositories you trust, and use workspace-write only there. codexFsScope: project closes the read and /tmp gaps on Codex 0.162.1 (Linux). It was checked through codex exec: a canary file outside the project, /tmp and a symlink pointing out of the project were all hidden (SPEC, Change 0.2.6). It relies on Codex keeping a permission profile alongside sandbox_mode, which OpenAI's docs say don't combine, so re-run that check after upgrading Codex, and until you have, keep codexSandbox at read-only.
/plugin install duet --marketplace bankielewicz/mod-builder
From a local clone (claude plugin marketplace add <folder> once):
/plugin install duet@claudemod
While developing: claude --plugin-dir <path-to>/duet. Changes load with /reload-plugins.
| Option | Default | What it does |
|---|---|---|
codexAgents | empty (only the duet: roles) | Comma-separated subagent types Codex runs, e.g. general-purpose, code-explorer. * routes every type, Claude Code's own Explore and Plan included. Named, isolated and nested calls stay on Claude. |
codexSandbox | read-only | read-only investigates and answers, writing nothing; workspace-write lets Codex write in the session folder and in /tmp. In both, Codex can read any file your account can read. Agents that can't edit always run read-only. There is no full-access option. |
codexModel | empty | -m for Codex; empty uses Codex's configured model. |
maxConcurrent | 2 | Codex runs at once; beyond it, the call is refused with a reason. |
timeoutMinutes | 20 | A Codex run past this is stopped and the call refused. If duet has to fall back to a one-shot run (streaming couldn't start), that run stops at 10 minutes at most. With timeoutMinutes above 10, /duet config says so, and so does the run's row while that run lasts. The fallback is rare: on Claude Code 2.1.300, streaming failed to start only when codex was missing or not executable, and then the one-shot run fails at once too. |
codexConsent | ask | ask before the first handoff of a session: a yes or a no holds for the rest of the session, and Esc asks again on the next handoff. allow pre-approves it (needed for claude -p). |
codexRunMode | background | wait or background for codexAgents types, then overrides by type: background, advisor=background, Explore=wait. Roles default to advisor wait, reviewer background, implementer wait. A run that can edit always waits. |
codexFsScope | anything | anything leaves Codex's own sandbox, which can read any file your account can read. project adds a duet permission profile and sets the sandbox with -c sandbox_mode instead of -s, which would switch the profile off. Codex can then read only the session folder, the system paths common tools need (:minimal), its install folders (the folder the first codex file on PATH really lands in, unless that folder holds the home folder or the project, $CODEX_HOME/packages and ~/.codex/packages), its own memories folder (~/.codex/memories, which Codex adds itself) and, in a linked worktree or a submodule, git's own folders, .git/config and its remote URLs included. It writes the session folder only under workspace-write. On Linux it sees no /tmp and no other files, even through a symlink, so tools that need /tmp (mktemp) fail. On macOS, Codex 0.162.1's source (seatbelt_scratch.rs) makes /private/tmp and /private/var/tmp readable and writable whenever the profile is used, read-only included; this hasn't been run. If the sandbox can't run Codex at all (an install folder duet didn't find, such as some npm/nvm or brew installs), the run fails, naming the binary. On Codex 0.162.1 (Linux), codex exec runs confirmed this for reads and /tmp under both sandboxes; whether the project stays unwritable under read-only was checked in codex sandbox only. /duet review gets the same profile, but whether it holds there isn't confirmed. Windows isn't checked. A session started in a subfolder of a repository can't run git. Each run's profile has a random name (duet-mod-fs-scope-<nonce>), so no Codex config can define it and merge in; a trusted repository's own .codex/config.toml can still shape Codex in other ways, such as MCP servers. Re-check after upgrading Codex (SPEC, Change 0.2.6). The consent question still describes the default. |
codexWritePaths | empty | With project and workspace-write: agent=folder;folder entries, comma-separated, e.g. red-test-writer=tests;test, impl-writer=src. A named agent writes only those project folders and reads the rest of the project; agents not named write the whole project. It only narrows. An absolute path or one that climbs out of the project (..) is ignored, and so is a folder whose real path leaves the project (a symlink out, or to the project itself), lies in .git, .codex or .agents (which Codex keeps read-only), or doesn't exist yet (Codex can't create it); the run's result names each folder ignored this way. An entry whose folders are all ignored leaves the project read-only. A malformed value (not agent=folder;folder) is refused by /duet config; set another way, it leaves every agent's project read-only. Through codex exec (Codex 0.162.1, Linux), a named folder was writable, and writes outside the project, to /tmp and through a symlink were refused. That the rest of the project stays read-only was checked in codex sandbox only. Codex leaves empty .agents, .aws, .codex and .git folders inside each folder it may write. |
An agent that writes files, like the example test-writer, needs codexSandbox: workspace-write to save anything on Codex. With the default it can only read and report.
-c approval_policy="never" under its own sandbox. Duet never passes --dangerously-bypass-* or --approve-for-me. Reviews are always read-only.read-only.codexAgents calls stay on Claude; a duet: role from inside a subagent or isolated is refused with the reason. There are no automatic retries, and a Codex failure is a visible refusal (or, for a background run, a delivered failure message), never a silent fallback to Claude.canContinueAgent: false).lost when duet reloaded.git status entry changed during the run (worded "during", since a background Claude subagent may have touched some; an extra edit to an already-modified file doesn't show). A read-only run skips the comparison.Mods are not sandboxed. They run with your access and skip Claude's permission prompts, so this table lists everything this one can touch. It is generated from claude plugin validate.
| Reach | Detail |
|---|---|
| Hooks | session.start, command.run{command=duet}, tool.call{tool=Agent}, turn.complete, ui.render{component=Pane, requestId=duet}, ui.render{component=Pane, requestId=duet-config} |
| Agent list | $.agent.register: duet:codex-advisor, duet:codex-reviewer, and duet:codex-implementer under workspace-write (a few hundred tokens of context in every session) |
| Prompts | $.prompt.submit: one turn per finished background Codex run (its answer or failure) and per background agent run a reload cut off |
| Settings | $.config.list / $.config.set on duet's own rows, from /duet config |
| Processes | $.process.spawn / $.process.run: codex exec --json …, codex exec review --json …, codex debug models, git rev-parse (the base commit and, under codexFsScope: project, --git-dir --git-common-dir), git status --porcelain --untracked-files=all and git hash-object -- <changed files> (before and after a run that can edit) |
| Files | lists ($.fs.list) and reads the .md files in every .claude/agents/ folder from the session folder up to the nearest folder holding .git (checked with $.fs.exists; with none, up to the filesystem root), and in ~/.claude/agents/, subfolders and symbolic links included (each folder's real path and each link's target through $.fs.stat; managed-settings, --agents, plugin and --add-dir agents aren't reached); under codexFsScope: project, resolves with $.fs.stat the session folder, each codexWritePaths folder and codex in each PATH folder; reads ~/.claude/duet-data/runs.jsonl (legacy) and every sessions/<session>/runs.jsonl, with $.fs.list on sessions/, for /duet stats; writes sessions/<session>/runs.jsonl (no prompt text), sessions/<session>/review-<id>.md and sessions/<session>/d<n>.out.md (a background run's answer; per session, since ids restart at d1) |
| Network | none directly; Codex sends the handoff and whatever it reads to OpenAI under your Codex sign-in. It can read any file your account can read, not only the session folder |
| Model calls | none by the mod directly; Codex usage per routed call and review; each delivered background answer is a Claude turn |
| Environment | reads CODEX_HOME, HOME, PATH, USERPROFILE; Codex's child env blanks the secrets named in BLANKED_ENV (see Install); other variables pass through |
| Asks | $.ui.ask before sending to Codex, until it is answered in the session (unless codexConsent: allow) |
| Gating hooks | tool.call{tool=Agent} with .catch (refuses on failure; never falls through) |
claude plugin validate .
claude plugin test .
Built on Claude Code 2.1.296 and codex-cli 0.162.1; 0.2.9 checked on Claude Code 2.1.300. Written spec-first with /mod-builder:build; see SPEC.md.
hooks/register.tsx 1115 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import {
5 agentResult, appendLog, buildHandoff, coerceConfigValue, configText, findConfigField, modelsText, modelWarning, parseModelCatalog, pickable, planDraft, storedText, CONFIG_FIELDS, CONFIG_USAGE, MODELS_ARGV, candidateOf, childEnv, codexArgv, codexLaunch, countsOf, demoTasks, effectiveSandbox,
6 EMPTY_RUN, EMPTY_TEXT, envelopeLines, failureOf, foldAll, foldEvent, footerOf, layoutOf, moved,
7 parseReviewArgs, pickAgent, readOptions, reviewArgv, routesText, rowsThatFit, rowText, runLineOf,
8 mergeLogs, splitLines, statsText, statusLineOf, subText, summarize, tokensOf, verdictOf, writePathsOf, folderUnder, isInside, isProtectedPath, parentOf, pathDirs, resolveFrom, DEFAULT_TYPE,
9 claudeTaskOf, deliveryText, launchedRecord, liveCount, lostOnReload, modeOf, pendingIdOf, roleDef, roleDescription, rolesFor, routeOf, routesLines, runsInBackground, READ_ONLY_CANDIDATE, ROLE_PREFIX,
10 paneAction, codexRunning, consentRefusal, consentText, fallbackFailure, fallbackNote, FALLBACK_MAX_MS, hashablePaths, limitText, sessionFolderOf, snapshotOf,
11} from './duet-logic'
12import type { AgentDef, ClaudePending, CodexModel, ConfigField, Envelope, FsScope, Options, ReviewTarget, Role, Run, Sandbox, Task } from './duet-logic'
13
14/**
15 * Duet: delegates chosen subagent types and its own `duet:` roles to Codex,
16 * runs independent Codex reviews, and measures every run. Archetype: pane +
17 * command, with a tool.call {tool:'Agent'} hook in guard form that answers
18 * routed calls: read-only runs at once (the answer arrives later through
19 * $.prompt.submit), editing runs in the foreground.
20 */
21
22const PANE = 'duet'
23/** The main pane as every open asks for it; bringing it to the front adds only `focus`. */
24const PANE_ARGS = { id: PANE, title: 'Duet', columns: 76 }
25const CONFIG_PANE = 'duet-config'
26const SHOW_TEXT = 'Bringing the pane to the front; it takes the keyboard if your prompt is empty (Esc gives it back).'
27const STILL_BEHIND_TOAST = 'Duet is still behind another tab: ctrl+x tab, then again until it shows.'
28/** How long after asking for the front the pane is read again (#31). */
29const FRONT_CHECK_MS = 500
30const COLORS = { accent: '#D97757', success: '#7FB685', warn: '#E0B15A', error: '#E06C6C' } as const
31const STATUS_COLOR = { running: COLORS.accent, done: COLORS.success, error: COLORS.error, refused: COLORS.warn } as const
32const YES = 'Yes, send to Codex'
33const NO = 'No'
34
35const tasks = atom({ plugin: 'duet', key: 'tasks' } as const, [])
36const selected = atom({ plugin: 'duet', key: 'selected' } as const, 0)
37const view = atom({ plugin: 'duet', key: 'view' } as const, 'tasks')
38const consent = atom({ plugin: 'duet', key: 'consent' } as const, 'unasked')
39const statsAtom = atom({ plugin: 'duet', key: 'statsText' } as const, '')
40const nowAtom = atom({ plugin: 'duet', key: 'now' } as const, 0)
41const isDemo = atom({ plugin: 'duet', key: 'isDemo' } as const, false)
42const draftAtom = atom({ plugin: 'duet', key: 'configDraft' } as const, {} as Record<string, string>)
43const noteAtom = atom({ plugin: 'duet', key: 'configNote' } as const, '')
44const savingAtom = atom({ plugin: 'duet', key: 'configSaving' } as const, false)
45const cfgAt = atom({ plugin: 'duet', key: 'configAt' } as const, 0)
46const editingAtom = atom({ plugin: 'duet', key: 'configEditing' } as const, '')
47const modelsAtom = atom({ plugin: 'duet', key: 'configModels' } as const, [] as CodexModel[])
48const modelsStateAtom = atom({ plugin: 'duet', key: 'configModelsState' } as const, 'idle')
49const pickingAtom = atom({ plugin: 'duet', key: 'configPicking' } as const, false)
50const reopenAtom = atom({ plugin: 'duet', key: 'configReopenAt' } as const, 0)
51const claudePending = atom({ plugin: 'duet', key: 'claudePending' } as const, {} as Record<string, ClaudePending>)
52/** A reload this long after a save began is that save's own; later ones leave the pane alone. */
53const REOPEN_WINDOW_MS = 60_000
54const MODEL_FIELD = CONFIG_FIELDS.find(f => f.field === 'codexModel')!
55
56let options: Options = readOptions({})
57let rawOptions: unknown = {}
58let ticker: Timer | null = null
59/** Codex runs this module instance started; a running row not in here was cut off by a reload. */
60const mine = new Set<string>()
61
62// ── Pane, status, ticker ─────────────────────────────────────────────────
63
64async function openPane($: EngineInterface): Promise<void> {
65 const opened = await $.ui.open(PANE_ARGS)
66 if (!opened.isPlaced) $.ui.toast('Duet needs a wider window. Widen it to see the pane, or watch the status line.')
67}
68
69async function paneActionOf($: EngineInterface): Promise<ReturnType<typeof paneAction>> {
70 return paneAction(await $.ui.panes(), PANE)
71}
72
73/**
74 * Asks for a pane that is a tab behind another to the front, with the keyboard, for a person who asked to see it (#31).
75 * The request goes out before the first await. A pane that is still a tab behind another afterwards is named in a toast.
76 */
77async function bringToFront($: EngineInterface): Promise<void> {
78 await $.ui.open({ ...PANE_ARGS, focus: true })
79 $.clock.after(FRONT_CHECK_MS, () => void warnIfBehind($).catch(() => undefined))
80}
81
82async function warnIfBehind($: EngineInterface): Promise<void> {
83 if ((await paneActionOf($)) === 'show') $.ui.toast(STILL_BEHIND_TOAST)
84}
85
86/** An open for a command that names the pane: a tab behind another comes to the front, any other pane is opened as before. */
87async function openOrFront($: EngineInterface): Promise<void> {
88 if ((await paneActionOf($)) === 'show') void bringToFront($).catch(() => undefined)
89 else await openPane($)
90}
91
92async function refreshStatus($: EngineInterface): Promise<void> {
93 $.ui.status(statusLineOf(await read($, tasks)))
94}
95
96/** One 1 s clock while anything runs; it stops itself when nothing does. */
97async function ensureTicker($: EngineInterface): Promise<void> {
98 await update($, nowAtom, () => Date.now())
99 if (ticker) return
100 ticker = $.clock.every(1000, () => {
101 void tick($).catch(() => undefined)
102 })
103}
104
105async function tick($: EngineInterface): Promise<void> {
106 const list = await read($, tasks)
107 const demo = await read($, isDemo)
108 if (!demo && !list.some(t => t.status === 'running')) {
109 ticker?.cancel()
110 ticker = null
111 return
112 }
113 await update($, nowAtom, () => Date.now())
114}
115
116async function putTask($: EngineInterface, task: Task): Promise<void> {
117 await update($, tasks, list => [task, ...list.filter(t => t.id !== task.id)])
118 await refreshStatus($)
119}
120
121/** Adds a refused row, its id taken inside the same update, so it can never land on a live row's id. */
122async function addRefused($: EngineInterface, make: (id: string) => Task): Promise<Task> {
123 let added: Task | null = null
124 await update($, tasks, list => {
125 added = make(idAfter(list))
126 return [added, ...list]
127 })
128 await refreshStatus($)
129 return added!
130}
131
132async function patchTask($: EngineInterface, id: string, patch: Partial<Task>): Promise<void> {
133 await update($, tasks, list => list.map(t => (t.id === id ? { ...t, ...patch } : t)))
134 await refreshStatus($)
135}
136
137function idAfter(list: Task[]): string {
138 const n = list.reduce((m, t) => Math.max(m, Number(t.id.slice(1)) || 0), 0)
139 return `d${n + 1}`
140}
141
142async function nextId($: EngineInterface): Promise<string> {
143 return idAfter(await read($, tasks))
144}
145
146/** Admits a run under the concurrency limit, its row added in the same write: the row, or null at the limit. */
147async function admit($: EngineInterface, make: (id: string) => Task): Promise<Task | null> {
148 let admitted: Task | null = null
149 await update($, tasks, list => {
150 const t = make(idAfter(list))
151 admitted = codexRunning(list) < options.maxConcurrent ? t : null
152 return admitted ? [t, ...list] : list
153 })
154 await refreshStatus($)
155 return admitted
156}
157
158// ── Files ────────────────────────────────────────────────────────────────
159
160/** The user's home folder: HOME, else USERPROFILE (Windows); '' when neither is set. */
161async function homeDir($: EngineInterface): Promise<string> {
162 return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
163}
164
165async function dataDir($: EngineInterface): Promise<string> {
166 return `${(await homeDir($)) || '.'}/.claude/duet-data`
167}
168
169/** This session's own folder, for per-task files and its run log: ids restart at d1 in every session. */
170async function sessionDir($: EngineInterface): Promise<string> {
171 const id = await $.session.id().catch(() => 'unknown')
172 return `${await dataDir($)}/sessions/${sessionFolderOf(id)}`
173}
174
175// Appends to this session's runs.jsonl one at a time (it is read, appended and rewritten); no other session writes it,
176// except that sessions whose id can't be read share the folder `unknown`.
177let logChain: Promise<void> = Promise.resolve()
178
179async function readText($: EngineInterface, path: string): Promise<string> {
180 try {
181 return String(await $.fs.read(path))
182 } catch {
183 return ''
184 }
185}
186
187/**
188 * A file's text, '' when it isn't there, or undefined when the read failed otherwise (too many open files, no
189 * permission). The engine's error names the errno only in its message (`… failed: ENOENT`, 2.1.300) and carries no
190 * code, so any other failure asks whether the file exists: a rewording can't make a missing log look unreadable.
191 */
192async function readIfThere($: EngineInterface, path: string): Promise<string | undefined> {
193 try {
194 return String(await $.fs.read(path))
195 } catch (err) {
196 if (/\bENOENT\b/.test(String(err))) return ''
197 return (await $.fs.exists(path).catch(() => true)) ? undefined : ''
198 }
199}
200
201/**
202 * Appends a run to this session's log, which is read and rewritten whole. A missing log starts a new one; a read that
203 * fails twice skips this run's line (every caller catches) rather than rewrite the log from nothing.
204 */
205async function appendRun($: EngineInterface, task: Task): Promise<void> {
206 const path = `${await sessionDir($)}/runs.jsonl`
207 const text = (await readIfThere($, path)) ?? (await readIfThere($, path))
208 if (text === undefined) throw new Error(`duet: ${path} could not be read, so this run is not logged`)
209 await $.fs.write(path, appendLog(text, runLineOf(task)))
210}
211
212function logRun($: EngineInterface, task: Task): Promise<void> {
213 logChain = logChain.catch(() => undefined).then(() => appendRun($, task))
214 return logChain
215}
216
217/**
218 * Every run log there is: the legacy shared one, then each session folder's own (a folder with no log reads as empty),
219 * read AGENT_READS at a time. A read that failed other than as missing is tried once more on its own, as agentTexts
220 * does; one that fails again reads as empty.
221 */
222async function readRunLogs($: EngineInterface): Promise<string[]> {
223 const data = await dataDir($)
224 const folders = await $.fs.list(`${data}/sessions`).catch(() => [])
225 const paths = [`${data}/runs.jsonl`, ...folders.map(e => `${data}/sessions/${e.name}/runs.jsonl`)]
226 const texts: (string | undefined)[] = []
227 for (let i = 0; i < paths.length; i += AGENT_READS) texts.push(...(await Promise.all(paths.slice(i, i + AGENT_READS).map(path => readIfThere($, path)))))
228 for (const [i, path] of paths.entries()) if (texts[i] === undefined) texts[i] = await readIfThere($, path)
229 return texts.map(text => text ?? '')
230}
231
232/** How many folders deep below a `.claude/agents` folder duet's own search goes. */
233const AGENT_DEPTH = 8
234/** How many `.md` files duet's own search reads per `.claude/agents` folder. */
235const AGENT_FILES = 500
236/** How many agent files, or run logs for stats, are read at once: enough to overlap the reads, few enough to stay far from the open-file limit. */
237const AGENT_READS = 16
238
239/** Agent files by path, read again only when their size or time changed; the folders are listed on every call. */
240const agentCache = new Map<string, { size: number; mtimeMs: number; text: string }>()
241
242type AgentEntry = { path: string; size: number; mtimeMs: number }
243/** Which of the search's limits a lookup reached: a folder it did not enter, or a `.md` file it did not read. */
244type AgentCut = { depth: boolean; files: boolean }
245
246/**
247 * The `.claude/agents` folders Claude scans, nearest first: the session folder up to the repository root,
248 * then the user's. As Claude does, the repository root is the nearest folder holding a `.git` entry, whether
249 * or not git can read it; without one the search goes up to the filesystem root.
250 */
251async function agentRoots($: EngineInterface, cwd: string): Promise<string[]> {
252 const start = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
253 const folders: string[] = []
254 for (let dir = start; ; dir = parentOf(dir)) {
255 folders.push(`${dir}/.claude/agents`)
256 if (parentOf(dir) === dir || (await $.fs.exists(`${dir}/.git`).catch(() => false))) break
257 }
258 const home = await homeDir($)
259 const user = `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude/agents`
260 return home && !folders.includes(user) ? [...folders, user] : folders
261}
262
263/**
264 * The `.md` files under an agents folder, subfolders and dot-folders included. A symbolic link is followed, as
265 * Claude follows it; a folder whose real path was already searched (a link loop, or a root linked into another)
266 * isn't searched again. A failed list is an empty folder. Past the limits, a folder isn't entered and a file
267 * isn't listed; `cut` records which limit hid a `.md` file, or a folder or link that may hold one.
268 */
269async function listAgentFiles($: EngineInterface, dir: string, searched: Set<string>, cut: AgentCut, found: AgentEntry[] = [], depth = 0): Promise<AgentEntry[]> {
270 const real = await realPathOf($, dir)
271 if (real === undefined || searched.has(real)) return found
272 const listing = await $.fs.list(dir).catch(() => [])
273 if (depth > AGENT_DEPTH || found.length >= AGENT_FILES) {
274 // A look inside, one level and no reads: only a folder that may hold a definition cuts the search short.
275 for (const e of listing) {
276 if (!(await mayHoldAgent($, `${dir}/${e.name}`, e, searched))) continue
277 if (depth > AGENT_DEPTH) cut.depth = true
278 else cut.files = true
279 break
280 }
281 return found
282 }
283 searched.add(real)
284 const entries = []
285 for (const e of [...listing].sort((a, b) => (a.name < b.name ? -1 : 1))) {
286 entries.push(e.isLink ? { ...e, ...(await $.fs.stat(`${dir}/${e.name}`).catch(() => ({ kind: 'other' as const }))) } : e)
287 }
288 for (const e of entries) {
289 if (e.kind !== 'file' || !e.name.endsWith('.md')) continue
290 if (found.length < AGENT_FILES) found.push({ path: `${dir}/${e.name}`, size: e.size, mtimeMs: e.mtimeMs })
291 else cut.files = true
292 }
293 for (const e of entries) {
294 if (e.kind === 'dir') await listAgentFiles($, `${dir}/${e.name}`, searched, cut, found, depth + 1)
295 }
296 return found
297}
298
299/**
300 * Whether an entry of a folder past a limit may hide a definition: a `.md` file or a folder. A link counts by where it
301 * leads, so one that leads nowhere, to a file not named `.md`, or back into a searched folder doesn't.
302 */
303async function mayHoldAgent($: EngineInterface, path: string, e: { name: string; kind: string; isLink: boolean }, searched: Set<string>): Promise<boolean> {
304 if (!e.isLink) return e.kind === 'dir' || (e.kind === 'file' && e.name.endsWith('.md'))
305 const to = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
306 if (to?.kind === 'file') return e.name.endsWith('.md')
307 return to?.kind === 'dir' && !searched.has(to.realPath ?? path)
308}
309
310/** An agent file's text, from the cache while its size and time match the listing. */
311async function agentText($: EngineInterface, e: AgentEntry): Promise<string> {
312 const cached = agentCache.get(e.path)
313 if (cached && cached.size === e.size && cached.mtimeMs === e.mtimeMs) return cached.text
314 const text = await readText($, e.path)
315 if (text) agentCache.set(e.path, { size: e.size, mtimeMs: e.mtimeMs, text })
316 else agentCache.delete(e.path)
317 return text
318}
319
320/**
321 * A folder's agent texts in the listing's order, read AGENT_READS at a time. A read that fails is tried once more on
322 * its own, since an error such as too many open files passes; one that fails again is left out, as Claude leaves out
323 * a file it can't read.
324 */
325async function agentTexts($: EngineInterface, entries: AgentEntry[]): Promise<string[]> {
326 const texts: string[] = []
327 for (let i = 0; i < entries.length; i += AGENT_READS) texts.push(...(await Promise.all(entries.slice(i, i + AGENT_READS).map(e => agentText($, e)))))
328 for (const [i, e] of entries.entries()) if (!texts[i]) texts[i] = await agentText($, e)
329 return texts
330}
331
332/** Forgets the cached files under a searched folder that its listing no longer holds, so the cache never outgrows the folders. */
333function pruneAgentCache(dir: string, listed: Set<string>): void {
334 for (const path of [...agentCache.keys()]) {
335 if (path.startsWith(`${dir}/`) && !listed.has(path)) agentCache.delete(path)
336 }
337}
338
339/** What Claude reads when a lookup reached a limit: a definition past it was never read, so the one used may not be Claude's. */
340function cutText(type: string, cut: AgentCut): string {
341 const limits = [...(cut.depth ? [`${AGENT_DEPTH} folders deep`] : []), ...(cut.files ? [`${AGENT_FILES} .md files in one .claude/agents folder`] : [])]
342 return limits.length ? `duet: the search for the ${type} agent definition was cut short at ${limits.join(' and ')}; a definition past that was not read, so Claude may use a different one, and duet ran this agent read-only.` : ''
343}
344
345/**
346 * The custom agent's definition by Claude's discovery rules: the nearest `.claude/agents` folder with a file
347 * named `type` in its `name` field; `isCut` when the search was cut short by its depth or file limit, and the note that says so.
348 * Agents from managed settings, `--agents`, a plugin or `--add-dir` aren't reachable, so they are a miss.
349 */
350async function agentDef($: EngineInterface, cwd: string, type: string): Promise<{ def: AgentDef | null; isCut: boolean; note: string }> {
351 if (!/^[\w.:-]+$/.test(type)) return { def: null, isCut: false, note: '' }
352 // A folder is listed and read only when every nearer one had no match, so each pick sees that folder alone.
353 const searched = new Set<string>()
354 const cut: AgentCut = { depth: false, files: false }
355 const listed = new Set<string>()
356 let def: AgentDef | null = null
357 for (const dir of await agentRoots($, cwd)) {
358 const entries = await listAgentFiles($, dir, searched, cut)
359 for (const e of entries) listed.add(e.path)
360 pruneAgentCache(dir, listed)
361 def = pickAgent(await agentTexts($, entries), type)
362 if (def) break
363 }
364 return { def, isCut: cut.depth || cut.files, note: cutText(type, cut) }
365}
366
367/** Where a path really lands, or undefined when it leads nowhere. */
368async function realPathOf($: EngineInterface, path: string): Promise<string | undefined> {
369 try {
370 return (await $.fs.stat(path, { resolve: true })).realPath
371 } catch {
372 return undefined
373 }
374}
375
376/** The write folders that really land inside the project: a symlink out of it, to it, into .git, .codex or .agents, or a folder not there yet is dropped. */
377async function safeWritePaths($: EngineInterface, cwd: string, folders: string[] | undefined): Promise<string[] | undefined> {
378 if (folders === undefined) return undefined
379 const root = await realPathOf($, cwd)
380 const kept: string[] = []
381 for (const folder of folders) {
382 const path = folderUnder(cwd, folder)
383 const real = root && path ? await realPathOf($, path) : undefined
384 if (root && real && isInside(root, real) && !isProtectedPath(root, real)) kept.push(folder)
385 }
386 return kept
387}
388
389// ── Git evidence ─────────────────────────────────────────────────────────
390
391async function git($: EngineInterface, args: string[]): Promise<string> {
392 try {
393 const r = await $.process.run(['git', '--no-optional-locks', ...args], { timeoutMs: 10_000 })
394 // Trim only the end: `git status --porcelain` lines start with a meaningful space.
395 return r.exitCode === 0 ? r.stdout.replace(/\s+$/, '') : ''
396 } catch {
397 return ''
398 }
399}
400
401/**
402 * Git's own folders, absolute: outside the session folder in a linked worktree or a submodule. Without
403 * --path-format (git 2.31 and later only), a relative answer is resolved against the session folder.
404 */
405async function gitDirs($: EngineInterface, cwd: string): Promise<string[]> {
406 return (await git($, ['rev-parse', '--git-dir', '--git-common-dir'])).split('\n').map(l => l.trim()).filter(Boolean).map(path => resolveFrom(cwd, path))
407}
408
409/** Where a path really lands when it leads to a file; undefined for a folder, anything else, or nothing. */
410async function fileRealPathOf($: EngineInterface, path: string): Promise<string | undefined> {
411 try {
412 const stat = await $.fs.stat(path, { resolve: true })
413 return stat.kind === 'file' ? stat.realPath : undefined
414 } catch {
415 return undefined
416 }
417}
418
419/**
420 * Codex's install folders: where the codex on PATH really lands (Codex runs itself from there in its sandbox), and
421 * $CODEX_HOME/packages. Like the shell, the lookup skips a codex that is not a file; it can't see the executable bit.
422 */
423async function codexInstallDirs($: EngineInterface): Promise<string[]> {
424 const codexHome = (await $.env.get('CODEX_HOME')) || ''
425 const dirs = codexHome ? [`${codexHome}/packages`] : []
426 for (const dir of pathDirs((await $.env.get('PATH')) || '')) {
427 const real = await fileRealPathOf($, `${dir}/codex`)
428 if (real) return [...dirs, parentOf(real)]
429 }
430 return dirs
431}
432
433/** What a project-scope run may read outside the session folder: git's own folders and Codex's install folders. */
434async function scopeReads($: EngineInterface, cwd: string): Promise<string[]> {
435 return [...(await gitDirs($, cwd)), ...(await codexInstallDirs($))]
436}
437
438type Scope = { fsScope: FsScope; home: string; reads: string[] }
439
440/** A run's scope, fixed when it is admitted: the option, the home folder, and under project scope what Codex may read outside it. */
441async function scopeOf($: EngineInterface, cwd: string): Promise<Scope> {
442 const fsScope = options.fsScope
443 return { fsScope, home: await homeDir($), reads: fsScope === 'project' ? await scopeReads($, cwd) : [] }
444}
445
446async function baseCommit($: EngineInterface): Promise<string> {
447 return (await git($, ['rev-parse', '--short', 'HEAD'])) || 'none'
448}
449
450/** The dirty tree with each file's content hash, for comparing before and after an editing run. */
451async function porcelain($: EngineInterface): Promise<string> {
452 const status = await git($, ['status', '--porcelain', '--untracked-files=all'])
453 const paths = hashablePaths(status).slice(0, 500)
454 const hashes: Record<string, string> = {}
455 if (paths.length) {
456 const out = (await git($, ['hash-object', '--', ...paths])).split('\n')
457 paths.forEach((path, at) => (hashes[path] = out[at] ?? ''))
458 }
459 return snapshotOf(status, hashes)
460}
461
462// ── Consent ──────────────────────────────────────────────────────────────
463
464/** '' when allowed; otherwise why not. Asks once per session. */
465async function ensureConsent($: EngineInterface, cwd: string): Promise<string> {
466 if (options.consent === 'allow') return ''
467 const now = await read($, consent)
468 if (now === 'yes') return ''
469 if (now === 'no') return 'you declined sending this repo to Codex this session'
470 let answer = ''
471 try {
472 answer = await $.ui.ask(consentText(cwd, options.sandbox), [YES, NO])
473 } catch (err) {
474 return consentRefusal(err)
475 }
476 const ok = answer === YES
477 await update($, consent, () => (ok ? 'yes' : 'no'))
478 return ok ? '' : 'you declined sending this repo to Codex this session'
479}
480
481// ── Running Codex ────────────────────────────────────────────────────────
482
483type Ran = { run: Run; failure: string }
484
485/** Runs Codex, folding its JSONL into the task as it streams; stops at the timeout. */
486async function runCodex($: EngineInterface, id: string, argv: string[], input: string | undefined): Promise<Ran> {
487 const launch = codexLaunch(argv, input)
488 let run = EMPTY_RUN
489 let carry = ''
490 let timedOut = false
491 let started = false
492 const stream = $.process.spawn(launch)
493 let elapsed = 0
494 const timer = $.clock.every(5000, () => {
495 elapsed += 5000
496 if (elapsed >= options.timeoutMs) {
497 timedOut = true
498 void stream.return?.(undefined as never)?.catch?.(() => undefined)
499 }
500 })
501 try {
502 let stderr = ''
503 for await (const chunk of stream) {
504 started = true
505 if (chunk.stream === 'stderr') {
506 stderr += chunk.text
507 continue
508 }
509 const split = splitLines(carry, chunk.text)
510 carry = split.carry
511 for (const line of split.lines) run = foldEvent(run, line)
512 if (run.text || run.isDone) await patchTask($, id, { tokens: tokensOf(run) })
513 if (timedOut) break
514 }
515 if (carry.trim()) run = foldEvent(run, carry)
516 if (timedOut) return { run, failure: `timed out after ${Math.round(options.timeoutMs / 60_000)}m` }
517 const end = await stream.result
518 return { run, failure: failureOf(run, end.code, stderr) }
519 } catch (err) {
520 if (started) return { run, failure: `codex stopped: ${String((err as Error)?.message ?? err).split('\n')[0]}` }
521 // spawn could not start: try a one-shot run (also the path tests exercise)
522 const cap = Math.min(options.timeoutMs, FALLBACK_MAX_MS)
523 // The cap cuts a longer timeoutMinutes: say so on the row while it runs.
524 const note = fallbackNote(options.timeoutMs)
525 if (note) await patchTask($, id, { detail: note }).catch(() => undefined)
526 const fallbackAt = Date.now()
527 try {
528 const r = await $.process.run(launch.argv, { env: launch.env, ...(launch.input !== undefined ? { stdin: launch.input } : {}), timeoutMs: cap })
529 const folded = foldAll(r.stdout)
530 return { run: folded, failure: failureOf(folded, r.exitCode, r.stderr) }
531 } catch (err2) {
532 return { run, failure: fallbackFailure(String((err2 as Error)?.message ?? err2), Date.now() - fallbackAt, cap) }
533 }
534 } finally {
535 timer.cancel()
536 }
537}
538
539// ── Routed subagent ──────────────────────────────────────────────────────
540
541type AgentCall = { subagent_type?: string; prompt: string; description: string }
542
543/** One admitted Codex run, everything its finish needs. */
544type Job = Scope & { id: string; type: string; cwd: string; writePaths: string[] | undefined; dropped: string[]; searchNote: string; sandbox: Sandbox; baseSha: string; before: string; prompt: string; envelope: Envelope; startedAt: number; task: Task }
545type Finished = { run: Run; failure: string; footer: string; endedAt: number }
546
547/** Runs Codex for an admitted job and records the outcome: row, log line, toast. */
548async function runJob($: EngineInterface, j: Job): Promise<Finished> {
549 const { run, failure } = await runCodex($, j.id, codexArgv({ sandbox: j.sandbox, model: options.model, cwd: j.cwd, fsScope: j.fsScope, home: j.home, writePaths: j.writePaths, reads: j.reads, nonce: crypto.randomUUID() }), j.prompt)
550 const endedAt = Date.now()
551 // A read-only run can't write, so a before/after comparison would only credit Claude's own edits to Codex.
552 const cand = j.sandbox === 'read-only' ? READ_ONLY_CANDIDATE : candidateOf(j.before, await porcelain($))
553 const done: Task = { ...j.task, envelope: { ...j.envelope, candidate: cand }, endedAt, tokens: tokensOf(run), status: failure ? 'error' : 'done', detail: failure }
554 await putTask($, done)
555 await logRun($, done).catch(() => undefined)
556 $.ui.toast((failure ? `${j.id} failed on codex: ${failure}` : `${j.id} done on codex · ${j.type}`).slice(0, 80))
557 const footer = footerOf({ id: j.id, model: options.model, sandbox: j.sandbox, base: j.baseSha, candidate: cand, dropped: j.dropped })
558 return { run, failure, endedAt, footer: j.searchNote ? `${j.searchNote}\n${footer}` : footer }
559}
560
561/** A background run: finishes the job, then hands the answer (or the failure) to the main session. */
562async function finishInBackground($: EngineInterface, j: Job, outputFile: string): Promise<void> {
563 let text: string
564 try {
565 const r = await runJob($, j)
566 await $.fs.write(outputFile, r.failure ? `duet ${j.id} failed on Codex: ${r.failure}\n${j.searchNote ? `${j.searchNote}\n` : ''}` : `${r.run.text}\n\n${r.footer}\n`).catch(() => undefined)
567 text = deliveryText({ id: j.id, label: j.type, model: options.model, sandbox: j.sandbox, durationMs: r.endedAt - j.startedAt, tokens: tokensOf(r.run), text: r.run.text, footer: r.footer, failure: r.failure, searchNote: j.searchNote })
568 } catch (err) {
569 const why = `duet failed unexpectedly (${String((err as Error)?.message ?? err).split('\n')[0]})`
570 await patchTask($, j.id, { status: 'error', endedAt: Date.now(), detail: why }).catch(() => undefined)
571 text = deliveryText({ id: j.id, label: j.type, model: options.model, sandbox: j.sandbox, durationMs: 0, tokens: 0, text: '', footer: '', failure: why })
572 } finally {
573 mine.delete(j.id)
574 }
575 // Never quietly: if the message can't be queued, the toast points at the answer on disk.
576 await $.prompt.submit({ text }).catch(() => $.ui.toast(`${j.id} ended on codex · answer in ${outputFile}`.slice(0, 80)))
577}
578
579async function routeToCodex($: EngineInterface, e: AgentCall, role: Role | null): Promise<{ deny: string } | { result: ReturnType<typeof agentResult> | ReturnType<typeof launchedRecord> }> {
580 const type = role ? ROLE_PREFIX + role.name : e.subagent_type?.trim() || DEFAULT_TYPE
581 const cwd = await $.session.cwd()
582 const startedAt = Date.now()
583 const rowOf = (id: string): Task => ({ id, provider: 'codex', kind: 'agent', subagentType: type, model: options.model, status: 'running', startedAt, endedAt: null, tokens: 0, description: e.description, detail: '', envelope: null })
584
585 const refuse = async (why: string) => {
586 await addRefused($, id => ({ ...rowOf(id), status: 'refused', endedAt: Date.now(), detail: why }))
587 return { deny: `duet: ${why}; the ${type} subagent did not run.` }
588 }
589
590 const blocked = await ensureConsent($, cwd)
591 if (blocked) return refuse(blocked)
592 const task = await admit($, rowOf)
593 if (!task) {
594 return refuse(limitText(codexRunning(await read($, tasks)), options.maxConcurrent))
595 }
596
597 const id = task.id
598 mine.add(id)
599 let handedOff = false
600 try {
601 const { def, isCut, note: searchNote } = role ? { def: roleDef(role), isCut: false, note: '' } : await agentDef($, cwd, type)
602 const sandbox = effectiveSandbox(options.sandbox, def, type, isCut)
603 // The run mode follows the sandbox the agent would have had: a demoted editing agent still waits, so Claude reads the note before going on.
604 const uncut = effectiveSandbox(options.sandbox, def, type)
605 const baseSha = await baseCommit($)
606 const before = sandbox === 'read-only' ? '' : await porcelain($)
607 const handoff = buildHandoff({ id, description: e.description, prompt: e.prompt, base: baseSha, cwd, sandbox, def, subagentType: type })
608 await patchTask($, id, { envelope: handoff.envelope })
609 await ensureTicker($)
610 const named = writePathsOf(options.writePaths, type)
611 const scope = await scopeOf($, cwd)
612 const writePaths = scope.fsScope === 'project' && sandbox === 'workspace-write' ? await safeWritePaths($, cwd, named) : named
613 const dropped = named && writePaths ? named.filter(folder => !writePaths.includes(folder)) : []
614 const job: Job = { ...scope, id, type, cwd, writePaths, dropped, searchNote, sandbox, baseSha, before, prompt: handoff.prompt, envelope: handoff.envelope, startedAt, task: { ...task, envelope: handoff.envelope } }
615
616 if (runsInBackground(options, uncut, type, role)) {
617 const outputFile = `${await sessionDir($)}/${id}.out.md`
618 await $.fs.write(outputFile, `duet ${id} (${type}) is running on Codex; its answer arrives as a message when it ends.\n`).catch(() => undefined)
619 handedOff = true
620 void finishInBackground($, job, outputFile).catch(() => undefined)
621 return { result: launchedRecord({ id, description: e.description, prompt: e.prompt, outputFile, model: options.model }) }
622 }
623
624 const r = await runJob($, job)
625 if (r.failure) return { deny: `duet: the Codex handoff failed (${r.failure}); the ${type} subagent did not run on Claude either.${searchNote ? `\n${searchNote}` : ''}` }
626 return { result: agentResult({ id, prompt: e.prompt, text: r.run.text, footer: r.footer, durationMs: r.endedAt - startedAt, run: r.run, model: options.model }) }
627 } catch (err) {
628 await patchTask($, id, { status: 'error', endedAt: Date.now(), detail: `duet failed unexpectedly (${String((err as Error)?.message ?? err).split('\n')[0]})` }).catch(() => undefined)
629 throw err
630 } finally {
631 if (!handedOff) mine.delete(id)
632 }
633}
634
635/** A subagent that stayed on Claude, measured from its own result. */
636async function measureClaude($: EngineInterface, e: AgentCall, result: unknown, startedAt: number): Promise<void> {
637 const r = result as { status?: string; totalDurationMs?: number; totalTokens?: number; resolvedModel?: string } | undefined
638 if (!r || r.status !== 'completed') return
639 const t: Task = {
640 id: `c${startedAt}`, provider: 'claude', kind: 'agent', subagentType: e.subagent_type?.trim() || DEFAULT_TYPE, model: r.resolvedModel ?? '',
641 status: 'done', startedAt, endedAt: startedAt + (r.totalDurationMs ?? 0), tokens: r.totalTokens ?? 0, description: e.description, detail: '', envelope: null,
642 }
643 await logRun($, t)
644}
645
646// ── Review ───────────────────────────────────────────────────────────────
647
648type ReviewStart = { started: boolean; reason: string }
649
650/** The gate: consent, then admission. Refused, it leaves a refused row and a toast and says why; admitted, it detaches the run and says started. */
651async function startReview($: EngineInterface, target: ReviewTarget): Promise<ReviewStart> {
652 const label = target.kind === 'uncommitted' ? '--uncommitted' : `--${target.kind} ${target.ref}`
653 const startedAt = Date.now()
654 const rowOf = (id: string): Task => ({ id, provider: 'codex', kind: 'review', subagentType: label, model: options.model, status: 'running', startedAt, endedAt: null, tokens: 0, description: `review ${label}`, detail: '', envelope: null })
655 const cwd = await $.session.cwd()
656 const refuse = async (why: string): Promise<ReviewStart> => {
657 const row = await addRefused($, id => ({ ...rowOf(id), status: 'refused', endedAt: Date.now(), detail: why }))
658 $.ui.toast(`${row.id} review refused: ${why}`.slice(0, 80))
659 return { started: false, reason: why }
660 }
661 const blocked = await ensureConsent($, cwd)
662 if (blocked) return refuse(blocked)
663 const admitted = await admit($, rowOf)
664 if (!admitted) return refuse(limitText(codexRunning(await read($, tasks)), options.maxConcurrent))
665 void finishReview($, admitted, target, cwd).catch(() => undefined)
666 return { started: true, reason: '' }
667}
668
669/** Runs Codex for an admitted review and records the outcome: row, log line, saved text, toast. */
670async function finishReview($: EngineInterface, task: Task, target: ReviewTarget, cwd: string): Promise<void> {
671 let done: Task
672 let text = ''
673 try {
674 await ensureTicker($)
675 const { run, failure } = await runCodex($, task.id, reviewArgv(target, options.model, { cwd, ...(await scopeOf($, cwd)), nonce: crypto.randomUUID() }), undefined)
676 done = { ...task, endedAt: Date.now(), tokens: tokensOf(run), status: failure ? 'error' : 'done', detail: failure || verdictOf(run.text) }
677 text = run.text
678 } catch (err) {
679 // Whatever fails after admission, the row must not stay running and hold a slot.
680 done = { ...task, endedAt: Date.now(), status: 'error', detail: `duet failed unexpectedly (${String((err as Error)?.message ?? err).split('\n')[0]})` }
681 }
682 await putTask($, done)
683 void logRun($, done).catch(() => undefined)
684 if (done.status === 'done') await $.fs.write(`${await sessionDir($)}/review-${task.id}.md`, text)
685 $.ui.toast((done.status === 'error' ? `${task.id} review failed: ${done.detail}` : `${task.id} review: ${done.detail}`).slice(0, 80))
686}
687
688// ── Commands ─────────────────────────────────────────────────────────────
689
690async function showStats($: EngineInterface): Promise<string> {
691 const text = statsText(summarize(mergeLogs(await readRunLogs($))))
692 await update($, statsAtom, () => text)
693 return text
694}
695
696async function toggleStats($: EngineInterface): Promise<void> {
697 if ((await read($, view)) === 'stats') {
698 await update($, view, () => 'tasks')
699 return
700 }
701 await showStats($)
702 await update($, view, () => 'stats')
703}
704
705async function startDemo($: EngineInterface): Promise<void> {
706 await update($, isDemo, () => true)
707 await update($, view, () => 'tasks')
708 await update($, selected, () => 0)
709 await ensureTicker($)
710}
711
712// How many task rows the pane last drew; selection never goes past them.
713let drawnRows = Number.POSITIVE_INFINITY
714
715async function move($: EngineInterface, by: number): Promise<void> {
716 const listed = (await read($, isDemo)) ? demoTasks(0).length : (await read($, tasks)).filter(t => t.provider === 'codex').length
717 await update($, selected, at => moved(at, by, Math.min(listed, drawnRows)))
718}
719
720/** Writes one option through the `/config` row's writer: '' once written, else why not. */
721async function writeField($: EngineInterface, f: ConfigField, value: string | number): Promise<string> {
722 const row = (await $.config.list()).find(r => r.key.endsWith(`.${f.field}`) && r.key.startsWith('duet'))
723 if (!row) return `the ${f.field} row is not in /config; use /plugin to configure duet.`
724 const r = await $.config.set({ key: row.key, value })
725 return r.deny ?? ''
726}
727
728/** `/duet config` opens the pane; `/duet config list` prints every option; `/duet config <field> <value>` changes one. */
729async function configure($: EngineInterface, words: string[]): Promise<string> {
730 const [name, ...value] = words
731 if (name === undefined) {
732 const opened = await $.ui.open({ id: CONFIG_PANE, title: 'Duet config', columns: 76, focus: true })
733 return opened.isPlaced ? 'Config pane opened: j/k move, e changes, s saves.' : `${configText(rawOptions)}\n(The window is too narrow for the config pane.)`
734 }
735 if (name.toLowerCase() === 'list') return configText(rawOptions)
736 if (name.toLowerCase() === 'models') {
737 const failed = await fetchModels($)
738 return failed ? `Could not list Codex's models: ${failed}` : modelsText(await read($, modelsAtom), options.model)
739 }
740 const f = findConfigField(name)
741 if (!f) return `no option "${name}". ${CONFIG_USAGE}`
742 if (value.length === 0) return `${f.field} needs a value. ${CONFIG_USAGE}`
743 const coerced = coerceConfigValue(f, value.join(' '))
744 if ('error' in coerced) return coerced.error
745 const deny = await writeField($, f, coerced.value)
746 if (deny) return `not changed: ${deny}`
747 const warning = f === MODEL_FIELD && coerced.value !== '' && !(await fetchModels($)) ? modelWarning(String(coerced.value), await read($, modelsAtom)) : ''
748 return `${f.field} = ${coerced.value === '' ? '(empty)' : coerced.value}. duet reloads with it.${warning ? `\nNote: ${warning}` : ''}`
749}
750
751/**
752 * Saves the pane's draft one field at a time. Each write reloads the module,
753 * which may cut this short; the draft and the saving flag live in $.state, so
754 * session.start of the new module calls this again and it writes what is left.
755 */
756async function saveDraft($: EngineInterface): Promise<void> {
757 const plan = planDraft(rawOptions, await read($, draftAtom))
758 if (plan.errors.length) {
759 await update($, savingAtom, () => false)
760 await update($, noteAtom, () => `Not saved: ${plan.errors.join(' ')}`)
761 return
762 }
763 await update($, savingAtom, () => true)
764 if (plan.writes.length) await update($, reopenAtom, () => Date.now())
765 for (const w of plan.writes) {
766 const deny = await writeField($, w.field, w.value)
767 if (deny) {
768 await update($, savingAtom, () => false)
769 await update($, noteAtom, () => `${w.field} not saved: ${deny}`)
770 await update($, reopenAtom, () => 0)
771 return
772 }
773 }
774 await update($, savingAtom, () => false)
775 await update($, draftAtom, () => ({}))
776 await update($, noteAtom, () => (plan.writes.length ? `Saved ${plan.writes.map(w => w.field.alias).join(', ')}.` : 'Nothing to save.'))
777 if (!plan.writes.length) await update($, reopenAtom, () => 0) // no write, no reload to wait for
778 await restorePane($)
779}
780
781/**
782 * Each write reloads the module, and a reload can drop the pane or the keys
783 * it held. Reopens it if gone and puts the keys back on it, so the next
784 * option can be edited without running /duet config again.
785 */
786async function restorePane($: EngineInterface): Promise<void> {
787 const isUp = await $.ui.panes().then(ps => ps.some(p => p.id === CONFIG_PANE), () => false)
788 if (!isUp) await $.ui.open({ id: CONFIG_PANE, title: 'Duet config', columns: 76, focus: true }).catch(() => undefined)
789 await $.ui.focus({ requestId: CONFIG_PANE, key: 'cfg-change' }).catch(() => undefined)
790}
791
792async function editDraft($: EngineInterface, f: ConfigField, text: string): Promise<void> {
793 await update($, draftAtom, d => ({ ...d, [f.field]: text }))
794 await update($, noteAtom, () => '')
795}
796
797async function moveConfig($: EngineInterface, by: number): Promise<void> {
798 await update($, editingAtom, () => '')
799 await update($, pickingAtom, () => false)
800 const at = await update($, cfgAt, at => moved(at, by, CONFIG_FIELDS.length))
801 // First time on the model row: fetch the catalog so a typo is flagged and the list opens without a wait.
802 if (CONFIG_FIELDS[at] === MODEL_FIELD && (await read($, modelsAtom)).length === 0 && (await read($, modelsStateAtom)) === 'idle') await fetchModels($)
803}
804
805/** Asks Codex for its model catalog (`codex debug models`, which refreshes it): '' once stored, else why not. */
806async function fetchModels($: EngineInterface): Promise<string> {
807 await update($, modelsStateAtom, () => 'fetching')
808 let failed = ''
809 try {
810 const r = await $.process.run(MODELS_ARGV, { env: childEnv(), timeoutMs: 30_000 })
811 const parsed = r.exitCode === 0 ? parseModelCatalog(r.stdout) : { error: (r.stderr.trim().split('\n')[0] || `codex exited ${r.exitCode}`) }
812 if ('error' in parsed) failed = parsed.error
813 else await update($, modelsAtom, () => parsed.models)
814 } catch (err) {
815 const msg = String((err as Error)?.message ?? err)
816 failed = /ENOENT|not found|No such file/i.test(msg) ? 'codex not found on PATH' : msg.split('\n')[0]!
817 }
818 await update($, modelsStateAtom, () => (failed ? failed : 'idle'))
819 return failed
820}
821
822/** A digit in the picker: that model (0: empty, Codex's default) into the draft. */
823async function pickModel($: EngineInterface, slug: string): Promise<void> {
824 await update($, editingAtom, () => '')
825 await update($, pickingAtom, () => false)
826 await editDraft($, MODEL_FIELD, slug)
827 await update($, noteAtom, () => `model → ${slug || '(empty)'}, not saved yet: s saves, u reverts.`)
828}
829
830/** e (or r) on the model row: opens the list, fetching Codex's catalog when there is none yet or on r. */
831async function openPicker($: EngineInterface, refresh: boolean): Promise<void> {
832 await update($, editingAtom, () => '')
833 await update($, pickingAtom, () => true)
834 if (refresh || ((await read($, modelsAtom)).length === 0 && (await read($, modelsStateAtom)) !== 'fetching')) await fetchModels($)
835}
836
837/** t in the open list: closes it and opens the text box for a slug the list does not show. */
838async function typeModel($: EngineInterface): Promise<void> {
839 await update($, pickingAtom, () => false)
840 await update($, editingAtom, () => MODEL_FIELD.field)
841 await $.ui.focus({ requestId: CONFIG_PANE, key: 'cfg-edit' }).catch(() => undefined)
842}
843
844/** e on the highlighted row: a choice field steps to its next choice; a text field opens its edit box. */
845async function changeConfig($: EngineInterface): Promise<void> {
846 const f = CONFIG_FIELDS[await read($, cfgAt)] ?? CONFIG_FIELDS[0]!
847 if (f.choices) {
848 const current = (await read($, draftAtom))[f.field] ?? storedText(rawOptions, f)
849 const next = f.choices[(f.choices.indexOf(current) + 1) % f.choices.length]!
850 await editDraft($, f, next)
851 return
852 }
853 if (f === MODEL_FIELD) return openPicker($, false)
854 await update($, editingAtom, () => f.field)
855 await $.ui.focus({ requestId: CONFIG_PANE, key: 'cfg-edit' }).catch(() => undefined)
856}
857
858async function finishEdit($: EngineInterface, f: ConfigField, text: string): Promise<void> {
859 await editDraft($, f, text)
860 await update($, editingAtom, () => '')
861}
862
863async function readReview($: EngineInterface, id: string): Promise<string> {
864 return readText($, `${await sessionDir($)}/review-${id}.md`)
865}
866
867export const register: Register = (on, raw) => {
868 rawOptions = raw
869 options = readOptions(raw)
870
871 on('session.start', async ($, e, next) => {
872 // Rows still running that this module didn't start were cut off by a reload: say so, never quietly.
873 let lost: Task[] = []
874 await update($, tasks, list => {
875 const r = lostOnReload(list, mine, Date.now())
876 lost = r.lost
877 return r.lost.length ? r.tasks : list
878 })
879 if (lost.length) {
880 await refreshStatus($)
881 for (const t of lost) {
882 void logRun($, t).catch(() => undefined)
883 if (t.kind === 'agent') void $.prompt.submit({ text: deliveryText({ id: t.id, label: t.subagentType, model: t.model, sandbox: t.envelope?.sandbox ?? 'read-only', durationMs: 0, tokens: 0, text: '', footer: '', failure: t.detail }) }).catch(() => undefined)
884 }
885 }
886 for (const r of rolesFor(options.sandbox)) {
887 const description = roleDescription(r, modeOf(options, ROLE_PREFIX + r.name, r))
888 await $.agent.register({ name: r.name, description, prompt: r.prompt, tools: r.tools }).catch(() => undefined)
889 }
890 if (await read($, savingAtom)) void saveDraft($).catch(() => undefined)
891 else {
892 const savedAt = await read($, reopenAtom)
893 if (savedAt > 0 && Date.now() - savedAt < REOPEN_WINDOW_MS) {
894 // The reload a save caused: the pane comes back, once.
895 await update($, reopenAtom, () => 0)
896 await restorePane($).catch(() => undefined)
897 }
898 }
899 await $.command
900 .register({ name: 'duet', description: 'Delegate subagents to Codex, review diffs independently, and compare runs', argumentHint: '[demo | review [--base <branch> | --commit <sha>] | show <id> | stats | routes | config [list | models | <field> <value>]]' })
901 .catch(() => undefined)
902 return next(e)
903 })
904
905 on('command.run', { command: 'duet' }, async ($, e) => {
906 try {
907 const [word = '', ...rest] = e.args.trim().split(/\s+/)
908 const sub = word.toLowerCase()
909 if (sub === 'demo') {
910 // The open goes out before this hook returns, ahead of the demo's state: an open after that is unasked.
911 await openOrFront($).catch(() => undefined)
912 void startDemo($).catch(() => undefined)
913 return { text: 'demo tasks (running, review, done, error, refused) are in the pane until this turn ends. Nothing runs.' }
914 }
915 if (sub === 'review') {
916 const target = parseReviewArgs(rest.join(' '))
917 if (!target) return { text: 'Usage: /duet review [--base <branch> | --commit <sha>] (default: uncommitted changes)' }
918 const { started, reason } = await startReview($, target)
919 if (!started) return { text: `Codex review not started: ${reason}` }
920 return { text: 'Codex review started (read-only). Its verdict lands in the pane and a toast; /duet show <id> prints it in full.' }
921 }
922 if (sub === 'show') {
923 const id = rest[0] ?? ''
924 const text = /^d\d+$/.test(id) ? await readReview($, id) : ''
925 return { text: text || `no saved review for "${id}".` }
926 }
927 if (sub === 'stats') return { text: await showStats($) }
928 if (sub === 'routes') return { text: routesText(options) }
929 if (sub === 'config') return { text: await configure($, rest) }
930 if (sub !== '') return { text: 'Usage: /duet [demo | review [--base <branch> | --commit <sha>] | show <id> | stats | routes | config [list | models | <field> <value>]]' }
931 const action = await paneActionOf($)
932 if (action === 'show') {
933 void bringToFront($).catch(() => undefined)
934 return { text: SHOW_TEXT }
935 }
936 void (action === 'close' ? $.ui.close({ id: PANE }) : openPane($)).catch(() => undefined)
937 return { text: action === 'close' ? 'Closed. /duet to reopen.' : `Opened. ${routesText(options)}` }
938 } catch {
939 return { text: 'something went wrong; see claude --debug.' }
940 }
941 })
942
943 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
944 // duet: roles always go to Codex (main loop only); codexAgents types unless named, isolated or nested.
945 const route = routeOf(options, e)
946 if (route.to === 'deny') return { deny: route.reason }
947 if (route.to === 'codex') return routeToCodex($, e, route.role)
948 const startedAt = Date.now()
949 const ran = await next(e)
950 if (!e.agentId && ran.deny === undefined && !ran.isError) {
951 // A background or teammate subagent is measured when its first turn ends.
952 const later = pendingIdOf(ran.result)
953 const pending: ClaudePending = { startedAt, type: e.subagent_type?.trim() || DEFAULT_TYPE, description: e.description }
954 if (later) void update($, claudePending, p => ({ ...p, [later]: pending })).catch(() => undefined)
955 else void measureClaude($, e, ran.result, startedAt).catch(() => undefined)
956 }
957 return ran
958 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'duet: the Codex handoff failed unexpectedly; the subagent did not run. See claude --debug.' }))
959
960 on('turn.complete', async ($, e, next) => {
961 const agentId = e.agentId
962 if (agentId) {
963 let taken: ClaudePending | undefined
964 await update($, claudePending, p => {
965 taken = p[agentId]
966 if (!taken) return p
967 const { [agentId]: _, ...rest } = p
968 return rest
969 })
970 if (taken) void logRun($, claudeTaskOf(taken, e, Date.now())).catch(() => undefined)
971 } else if (await read($, isDemo)) {
972 await update($, isDemo, () => false)
973 await update($, selected, () => 0)
974 }
975 return next(e)
976 })
977
978 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
979 const { Box, Text, Button } = $.ui.resolve(e)
980 const demo = await read($, isDemo)
981 const now = (await read($, nowAtom)) || Date.now()
982 const list = demo ? demoTasks(now) : (await read($, tasks)).filter(t => t.provider === 'codex')
983 const at = await read($, selected)
984 const mode = await read($, view)
985 const width = Math.max(20, e.props.bodyColumns - 2)
986 const layout = layoutOf(width)
987 const room = rowsThatFit(e.props.scroll.bodyRows)
988 drawnRows = room
989 const focused = e.props.isFocused
990 const head = layout === 'compact' ? 'DUET' : 'D U E T'
991 const counts = list.length ? countsOf(list) : ''
992 return (
993 <Box flexDirection="column" paddingX={1}>
994 <Box justifyContent="space-between">
995 <Text color={COLORS.accent} bold={focused}>{head}</Text>
996 <Text dimColor>{counts}</Text>
997 </Box>
998 <Text dimColor>{'─'.repeat(width)}</Text>
999 {mode === 'stats' ? (
1000 <Text>{(await read($, statsAtom)) || 'No runs recorded yet.'}</Text>
1001 ) : list.length === 0 ? (
1002 <Text dimColor>{EMPTY_TEXT}</Text>
1003 ) : (
1004 list.slice(0, room).map((t, i) => (
1005 <Box key={`t-${t.id}`} flexDirection="column">
1006 <Text color={STATUS_COLOR[t.status]} inverse={focused && i === at}>
1007 {(i === at ? '▸ ' : ' ') + rowText(t, now, width - 2)}
1008 </Text>
1009 {layout !== 'compact' && <Text dimColor>{' ' + subText(t, width - 4)}</Text>}
1010 {focused && i === at && t.envelope && layout !== 'compact' &&
1011 envelopeLines(t.envelope).map((line, j) => <Text key={`env-${t.id}-${j}`} dimColor>{' ' + line}</Text>)}
1012 </Box>
1013 ))
1014 )}
1015 <Text dimColor>{'─'.repeat(width)}</Text>
1016 {layout !== 'compact' && routesLines(options).map((line, i) => <Text key={`routes-${i}`} dimColor>{line.slice(0, width)}</Text>)}
1017 <Box gap={2}>
1018 <Button key="review" label="Review diff" hotkey="r" variant="primary" onPress={() => void startReview($, { kind: 'uncommitted' }).catch(() => undefined)} />
1019 <Button key="stats" label={mode === 'stats' ? 'Tasks' : 'Stats'} hotkey="s" plain onPress={() => void toggleStats($).catch(() => undefined)} />
1020 <Button key="next" label="Next" hotkey="j" plain onPress={() => void move($, 1).catch(() => undefined)} />
1021 <Button key="prev" label="Prev" hotkey="k" plain onPress={() => void move($, -1).catch(() => undefined)} />
1022 <Button key="close" label="Close" hotkey="c" plain onPress={() => void $.ui.close({ id: PANE }).catch(() => undefined)} />
1023 </Box>
1024 {layout !== 'compact' && <Text dimColor wrap="truncate">ctrl+x tab: keys, again: next tab · r review s stats j/k c close</Text>}
1025 </Box>
1026 )
1027 })
1028
1029 on('ui.render', { component: 'Pane', requestId: CONFIG_PANE }, async ($, e) => {
1030 const els = $.ui.resolve(e)
1031 if (!('Input' in els)) {
1032 const { Box, Text } = els
1033 return (
1034 <Box flexDirection="column" paddingX={1}>
1035 <Text>{configText(rawOptions)}</Text>
1036 <Text dimColor>This surface draws no fields: change one with /duet config field value.</Text>
1037 </Box>
1038 )
1039 }
1040 const { Box, Text, Button, Input } = els
1041 const draft = await read($, draftAtom)
1042 const note = await read($, noteAtom)
1043 const saving = await read($, savingAtom)
1044 const at = Math.min(await read($, cfgAt), CONFIG_FIELDS.length - 1)
1045 const editingField = await read($, editingAtom)
1046 const editing = CONFIG_FIELDS.find(f => f.field === editingField)
1047 const width = Math.max(20, e.props.bodyColumns - 2)
1048 const dirty = planDraft(rawOptions, draft)
1049 const isDirty = dirty.writes.length > 0 || dirty.errors.length > 0
1050 const sel = CONFIG_FIELDS[at]!
1051 const onModel = sel === MODEL_FIELD
1052 const models = onModel ? await read($, modelsAtom) : []
1053 const modelsState = onModel ? await read($, modelsStateAtom) : 'idle'
1054 const picking = onModel && (await read($, pickingAtom))
1055 const modelNow = draft[MODEL_FIELD.field] ?? storedText(rawOptions, MODEL_FIELD)
1056 const picks = pickable(models)
1057 const warning = onModel ? modelWarning(modelNow, models) : ''
1058 return (
1059 <Box flexDirection="column" paddingX={1}>
1060 <Box justifyContent="space-between">
1061 <Text color={COLORS.accent} bold={e.props.isFocused}>DUET CONFIG</Text>
1062 <Text dimColor>{saving ? 'saving…' : isDirty ? 'unsaved changes' : 'saved'}</Text>
1063 </Box>
1064 <Text dimColor>{'─'.repeat(width)}</Text>
1065 {CONFIG_FIELDS.map((f, i) => {
1066 const stored = storedText(rawOptions, f)
1067 const shown = draft[f.field] ?? stored
1068 const changed = draft[f.field] !== undefined && draft[f.field] !== stored
1069 const value = shown === '' ? '(empty)' : shown
1070 const choices = f.choices ? ` [${f.choices.join(' | ')}]` : ''
1071 const line = `${i === at ? '▸' : ' '} ${changed ? '•' : ' '} ${f.alias.padEnd(8)} ${value}${choices}`
1072 return <Text key={`cfg-row-${f.field}`} inverse={i === at} color={changed ? COLORS.warn : undefined}>{line.slice(0, width)}</Text>
1073 })}
1074 <Text dimColor>{'─'.repeat(width)}</Text>
1075 <Text dimColor>{(sel.field + ': ' + sel.help).slice(0, width)}</Text>
1076 {picking && !editing && (
1077 <Box key="cfg-models" flexDirection="column">
1078 {modelsState === 'fetching' && <Text dimColor>Asking Codex for its models…</Text>}
1079 {modelsState !== 'fetching' && modelsState !== 'idle' && <Text color={COLORS.error}>{`Could not list Codex's models: ${modelsState}`.slice(0, width)}</Text>}
1080 {picks.length > 0 && (
1081 <Button key="cfg-pick-0" hotkey="0" plain onPress={() => void pickModel($, '').catch(() => undefined)}>
1082 {`${modelNow === '' ? '✓' : ' '} (empty) Codex's own configured model`.slice(0, width - 3)}
1083 </Button>
1084 )}
1085 {picks.map((m, i) => (
1086 <Button key={`cfg-pick-${m.slug}`} hotkey={String(i + 1)} plain onPress={() => void pickModel($, m.slug).catch(() => undefined)}>
1087 {`${m.slug === modelNow ? '✓' : ' '} ${m.slug.padEnd(26)} ${m.description || m.name}`.slice(0, width - 3)}
1088 </Button>
1089 ))}
1090 </Box>
1091 )}
1092 {warning !== '' && <Text color={COLORS.warn}>{warning.slice(0, width)}</Text>}
1093 {editing && (
1094 <Input key="cfg-edit" label={`${editing.alias}: `} value={draft[editing.field] ?? storedText(rawOptions, editing)} submitLabel="set" autoFocus
1095 placeholder={editing.kind === 'number' ? String(editing.fallback) : '(empty)'}
1096 onInput={(v: string) => void editDraft($, editing, v).catch(() => undefined)}
1097 onSubmit={(v: string) => void finishEdit($, editing, v).catch(() => undefined)} />
1098 )}
1099 {note !== '' && <Text color={note.startsWith('Saved') || note === 'Nothing to save.' ? COLORS.success : note.startsWith('model →') ? undefined : COLORS.warn}>{note}</Text>}
1100 <Box gap={2}>
1101 <Button key="cfg-change" label={sel.choices ? 'Next choice' : onModel ? 'Choose model' : 'Edit'} hotkey="e" variant="primary" onPress={() => void changeConfig($).catch(() => undefined)} />
1102 {picking && <Button key="cfg-type" label="Type one" hotkey="t" plain onPress={() => void typeModel($).catch(() => undefined)} />}
1103 {onModel && <Button key="cfg-refresh" label={models.length ? 'Refresh models' : 'List models'} hotkey="r" plain onPress={() => void openPicker($, true).catch(() => undefined)} />}
1104 <Button key="cfg-next" label="Down" hotkey="j" plain onPress={() => void moveConfig($, 1).catch(() => undefined)} />
1105 <Button key="cfg-prev" label="Up" hotkey="k" plain onPress={() => void moveConfig($, -1).catch(() => undefined)} />
1106 <Button key="cfg-save" label="Save" hotkey="s" plain onPress={() => void saveDraft($).catch(() => undefined)} />
1107 <Button key="cfg-revert" label="Revert" hotkey="u" plain onPress={() => void (async () => { await update($, draftAtom, () => ({})); await update($, editingAtom, () => ''); await update($, pickingAtom, () => false); await update($, noteAtom, () => '') })().catch(() => undefined)} />
1108 <Button key="cfg-close" label="Close" hotkey="c" plain onPress={() => void $.ui.close({ id: CONFIG_PANE }).catch(() => undefined)} />
1109 </Box>
1110 <Text dimColor>{editing ? 'type the value, enter: set · esc: back to the keys' : picking ? '0-9: pick · t: type one · r: refresh · j/k: move · c: close' : onModel ? 'e: choose a model · r: refresh · j/k: move · s: save · c: close' : 'j/k: move · e: change · s: save · u: revert · c: close'}</Text>
1111 </Box>
1112 )
1113 })
1114}
1115hooks/duet-logic.ts 1336 lines1/**
2 * Duet's rules, free of `$` so tests can call them: routing, custom-agent
3 * definitions, the handoff envelope, Codex argv/env, the JSONL fold, the
4 * Agent result Claude reads, the run log and the pane's text.
5 */
6
7import type { ClaudePending, CodexModel, Envelope, Sandbox, Task, TaskStatus } from '../types'
8
9export type { ClaudePending, CodexModel, Envelope, Sandbox, Task, TaskStatus }
10
11export const DEFAULT_TYPE = 'general-purpose'
12const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']
13
14// ── Options ──────────────────────────────────────────────────────────────
15
16export type Options = {
17 routes: Set<string> | '*'
18 sandbox: Sandbox
19 model: string
20 maxConcurrent: number
21 timeoutMs: number
22 consent: 'ask' | 'allow'
23 runModes: RunModes
24 fsScope: FsScope
25 writePaths: WritePaths
26}
27
28/** `codexWritePaths`: project folders per agent type, or the error a malformed value gives (every agent then writes nothing). */
29export type WritePaths = Record<string, string[]> | { error: string }
30
31/** `codexFsScope`: `project` confines Codex's filesystem to a duet profile; `anything` leaves Codex's own sandbox as it was. */
32export type FsScope = 'project' | 'anything'
33
34/** Whether a Codex run makes Claude wait for it or answers later. */
35export type RunMode = 'wait' | 'background'
36/** `codexRunMode`: the default for `codexAgents` types, and per-type overrides (roles by any of their names). */
37export type RunModes = { fallback: RunMode; overrides: Record<string, RunMode> }
38
39const ROLE_ALIASES: Record<string, string> = { advisor: 'duet:codex-advisor', reviewer: 'duet:codex-reviewer', implementer: 'duet:codex-implementer' }
40
41function modeWord(w: string): RunMode | null {
42 const t = w.trim().toLowerCase()
43 return t === 'background' || t === 'bg' || t === 'async' ? 'background' : t === 'wait' || t === 'foreground' || t === 'sync' ? 'wait' : null
44}
45
46/** `advisor`, `codex-advisor` and `duet:codex-advisor` all name the advisor role; any other type stays as typed. */
47export function typeKey(name: string): string {
48 const t = name.trim()
49 const low = t.toLowerCase()
50 if (ROLE_ALIASES[low]) return ROLE_ALIASES[low]!
51 if (/^codex-(advisor|reviewer|implementer)$/.test(low)) return `duet:${low}`
52 if (/^duet:codex-(advisor|reviewer|implementer)$/.test(low)) return low
53 return t
54}
55
56/** `"background, advisor=wait, Explore=wait"` → modes, or `{ error }` naming the part that doesn't fit. */
57export function parseRunModes(text: string): RunModes | { error: string } {
58 const parts = text.split(',').map(p => p.trim()).filter(Boolean)
59 // No prototype: a type named constructor or __proto__ is just a key.
60 const modes: RunModes = { fallback: 'background', overrides: Object.create(null) as Record<string, RunMode> }
61 let hasDefault = false
62 for (const part of parts) {
63 const eq = part.indexOf('=')
64 if (eq === -1) {
65 const m = modeWord(part)
66 if (!m) return { error: `codexRunMode: "${part}" is not wait or background.` }
67 if (hasDefault) return { error: `codexRunMode: "${part}" is a second default; give one, then type=mode overrides.` }
68 modes.fallback = m
69 hasDefault = true
70 continue
71 }
72 const key = part.slice(0, eq).trim()
73 const m = modeWord(part.slice(eq + 1))
74 if (!key || !/^[\w.:-]+$/.test(key)) return { error: `codexRunMode: "${part}" needs a type before =, e.g. advisor=wait.` }
75 if (!m) return { error: `codexRunMode: "${part}" needs wait or background after =.` }
76 modes.overrides[typeKey(key)] = m
77 }
78 return modes
79}
80
81/** A run's mode: an override for its type, else its role's default, else the configured default. */
82export function modeOf(o: Options, type: string, role: Role | null): RunMode {
83 const key = typeKey(type)
84 const own = Object.prototype.hasOwnProperty.call(o.runModes.overrides, key) ? o.runModes.overrides[key] : undefined
85 return own ?? role?.mode ?? o.runModes.fallback
86}
87
88/** `"red-test-writer=tests;test, impl=src"` → project folders per agent type (roles by any of their names), or `{ error }` naming the part that isn't agent=folder;folder. */
89export function parseWritePaths(text: string): WritePaths {
90 // No prototype: a type named constructor or __proto__ is just a key.
91 const paths: Record<string, string[]> = Object.create(null)
92 for (const raw of text.split(',')) {
93 const part = raw.trim()
94 if (!part) continue
95 const eq = part.indexOf('=')
96 const key = eq === -1 ? '' : part.slice(0, eq).trim()
97 const folders = eq === -1 ? [] : part.slice(eq + 1).split(';').map(f => f.trim()).filter(Boolean)
98 if (!/^[\w.:-]+$/.test(key) || !folders.length) return { error: `codexWritePaths: "${part}" needs agent=folder;folder, e.g. red-test-writer=tests;test.` }
99 paths[typeKey(key)] = folders
100 }
101 return paths
102}
103
104/** The folders one agent type may write: none when the option is malformed, undefined when the type isn't named (the whole project). */
105export function writePathsOf(w: WritePaths, type: string): string[] | undefined {
106 if (typeof w.error === 'string') return []
107 const key = typeKey(type)
108 return Object.prototype.hasOwnProperty.call(w, key) ? (w as Record<string, string[]>)[key] : undefined
109}
110
111/** `"general-purpose, Explore"` → a set; `"*"` → every type; blank → none. */
112export function parseRoutes(text: string): Set<string> | '*' {
113 const t = text.trim()
114 if (t === '*') return '*'
115 return new Set(t.split(',').map(s => s.trim()).filter(Boolean))
116}
117
118/** Reads the manifest's options defensively; never anything above workspace-write. */
119export function readOptions(raw: unknown): Options {
120 const o = (raw ?? {}) as Record<string, unknown>
121 const num = (v: unknown, d: number) => (Number.isFinite(Number(v)) && String(v ?? '') !== '' ? Number(v) : d)
122 return {
123 routes: parseRoutes(String(o.codexAgents ?? '')),
124 sandbox: o.codexSandbox === 'workspace-write' ? 'workspace-write' : 'read-only',
125 model: String(o.codexModel ?? '').trim(),
126 maxConcurrent: Math.max(0, Math.floor(num(o.maxConcurrent, 2))),
127 timeoutMs: Math.max(1, num(o.timeoutMinutes, 20)) * 60_000,
128 consent: o.codexConsent === 'allow' ? 'allow' : 'ask',
129 fsScope: o.codexFsScope === 'project' ? 'project' : 'anything',
130 writePaths: parseWritePaths(String(o.codexWritePaths ?? '')),
131 runModes: ((m) => ('error' in m ? { fallback: 'background' as const, overrides: Object.create(null) as Record<string, RunMode> } : m))(parseRunModes(String(o.codexRunMode ?? 'background'))),
132 }
133}
134
135export function isRouted(routes: Options['routes'], subagentType: string | undefined): boolean {
136 const type = subagentType?.trim() || DEFAULT_TYPE
137 return routes === '*' ? true : routes.has(type)
138}
139
140/** What `codexAgents` leaves on Claude even when a type is listed. */
141export const EXCEPTIONS = 'named, isolated and nested calls stay on Claude'
142
143const SAY: Record<RunMode, string> = { wait: 'waits', background: 'background' }
144
145/** Overrides for types that aren't roles, as `Explore waits`. */
146function typeOverrides(o: Options): string {
147 return Object.entries(o.runModes.overrides).filter(([k]) => !k.startsWith(ROLE_PREFIX)).map(([k, m]) => `${k} ${SAY[m]}`).join(', ')
148}
149
150/** `/duet` and `/duet routes`: the routes, what they skip, how each runs, the roles, and the limits. */
151export function routesText(o: Options): string {
152 const list = o.routes === '*' ? 'every subagent type' : o.routes.size ? [...o.routes].join(', ') : 'nothing routed'
153 const more = typeOverrides(o)
154 const routes = list === 'nothing routed' ? `codex ← ${list}` : `codex ← ${list} (${EXCEPTIONS}), ${SAY[o.runModes.fallback]}${more ? `; ${more}` : ''}`
155 const roles = rolesFor(o.sandbox).map(r => `${ROLE_PREFIX}${r.name} (${SAY[modeOf(o, ROLE_PREFIX + r.name, r)]})`).join(', ')
156 const editing = o.sandbox === 'workspace-write' ? ' · editing runs wait' : ''
157 return `${routes} · roles ${roles} · sandbox ${o.sandbox} · max ${o.maxConcurrent}${editing}`
158}
159
160/** The pane's two footer lines (wide layout): routes with their exceptions, then each role's mode and the limits. */
161export function routesLines(o: Options): [string, string] {
162 const list = o.routes === '*' ? 'every type' : o.routes.size ? [...o.routes].join(', ') : 'nothing routed'
163 const routes = `routes codex ← ${list}${list === 'nothing routed' ? '' : ' · named, isolated, nested stay Claude'}`
164 const short: Record<RunMode, string> = { wait: 'waits', background: 'bg' }
165 const roles = rolesFor(o.sandbox).map(r => `${r.name.replace('codex-', '')} ${short[modeOf(o, ROLE_PREFIX + r.name, r)]}`)
166 const others = list === 'nothing routed' ? [] : [`others ${short[o.runModes.fallback]}`]
167 return [routes, `roles ${[...roles, ...others].join(' · ')} ${o.sandbox} · max ${o.maxConcurrent}`]
168}
169
170// ── Codex roles (registered agent types) ─────────────────────────────────
171
172export const ROLE_PREFIX = 'duet:'
173
174export type Role = { name: string; mode: RunMode; description: string; prompt: string; tools: string[]; editing: boolean }
175
176/** The line Claude reads for a role, ending with how it answers under the current mode. */
177export function roleDescription(role: Role, mode: RunMode): string {
178 const how = role.editing || mode === 'wait' ? 'Claude waits for its answer' : 'Answers in the background, as a message when it ends'
179 return `${role.description} ${how}; it can't be messaged afterwards.`
180}
181
182export const ROLES: Role[] = [
183 {
184 name: 'codex-advisor',
185 mode: 'wait',
186 editing: false,
187 tools: ['Read', 'Grep', 'Glob'],
188 description: `Runs on Codex (OpenAI), not Claude. A second opinion from another model family on a plan, design, approach or root cause. Reads the repo; never edits.`,
189 prompt: [
190 'You are an advisor giving a second opinion from a different model family.',
191 'Read what you need in the repository to check the claims in the task; do not edit anything.',
192 'Lead with your recommendation in one or two sentences, then the reasons, each tied to evidence (path:line, command output) where the repo can show it.',
193 "Say plainly where you disagree with the approach in the task, what you would do instead, and what would change your mind. Separate what you verified from what you assume.",
194 ].join('\n'),
195 },
196 {
197 name: 'codex-reviewer',
198 mode: 'background',
199 editing: false,
200 tools: ['Read', 'Grep', 'Glob', 'Bash'],
201 description: `Runs on Codex (OpenAI), not Claude. Adversarial review of a diff or files: judges the code that is there, ignores any summary of intent, reports findings with file:line and severity. Never edits.`,
202 prompt: [
203 'You are an adversarial reviewer. Judge only the code that is there; trust no summary of intent, including the task\'s, unless it is the specification itself.',
204 'Look for correctness bugs first: for each, give path:line, severity (high/medium/low), what breaks, and an input that breaks it. Then risks the tests do not cover.',
205 'Do not fix anything and do not edit files.',
206 'End with one line: "verdict: approve" or "verdict: changes requested (N high, N medium, N low)".',
207 ].join('\n'),
208 },
209 {
210 name: 'codex-implementer',
211 mode: 'wait',
212 editing: true,
213 tools: ['Read', 'Grep', 'Glob', 'Bash', 'Edit', 'Write'],
214 description: 'Runs on Codex (OpenAI), not Claude. Makes a scoped change under the session folder (no commits, pushes, installs or network) and reports what it changed.',
215 prompt: [
216 'You implement one scoped change under the session folder. Read the surrounding code first and match its style.',
217 'Make the smallest change that does the task; do not refactor beyond it. Run the project\'s tests or checks if you can, and report the result.',
218 'Never commit, push, install packages or reach the network.',
219 'End with the list of files you changed and what you verified.',
220 ].join('\n'),
221 },
222]
223
224/** The roles duet registers: the implementer only where Codex may edit. */
225export function rolesFor(sandbox: Sandbox): Role[] {
226 return ROLES.filter(r => !r.editing || sandbox === 'workspace-write')
227}
228
229/** The role a `subagent_type` names (`duet:codex-advisor`, or the bare name), else null. */
230export function roleOf(subagentType: string | undefined): Role | null {
231 const t = subagentType?.trim() ?? ''
232 if (!t.startsWith(ROLE_PREFIX)) return null
233 return ROLES.find(r => r.name === t.slice(ROLE_PREFIX.length)) ?? null
234}
235
236/** A role as an agent definition, so the handoff and the sandbox clamp treat it like an agent file. */
237export function roleDef(role: Role): AgentDef {
238 return { name: role.name, description: role.description, tools: role.tools, body: role.prompt }
239}
240
241// ── Routing ──────────────────────────────────────────────────────────────
242
243export type AgentCallShape = { subagent_type?: string; name?: string; team_name?: string; isolation?: string; agentId?: string }
244export type Route = { to: 'codex'; role: Role | null } | { to: 'claude' } | { to: 'deny'; reason: string }
245
246/**
247 * Where an Agent call goes. A `duet:` role always goes to Codex (its name is
248 * the explicit choice), but never from inside a subagent or isolated. A type
249 * in `codexAgents` goes to Codex unless the call is named, isolated or nested.
250 */
251export function routeOf(o: Options, e: AgentCallShape): Route {
252 const role = roleOf(e.subagent_type)
253 if (role) {
254 const label = ROLE_PREFIX + role.name
255 if (e.agentId) return { to: 'deny', reason: `duet: Codex roles run only from the main session; the ${label} subagent did not run.` }
256 if (e.isolation) return { to: 'deny', reason: `duet: Codex roles can't run isolated (worktree/remote); the ${label} subagent did not run.` }
257 return { to: 'codex', role }
258 }
259 if (e.agentId || e.name || e.team_name || e.isolation || !isRouted(o.routes, e.subagent_type)) return { to: 'claude' }
260 return { to: 'codex', role: null }
261}
262
263/** Whether a routed run answers at once: never one that can edit, else as its mode says. */
264export function runsInBackground(o: Options, sandbox: Sandbox, type: string, role: Role | null): boolean {
265 return sandbox === 'read-only' && modeOf(o, type, role) === 'background'
266}
267
268// ── /duet config ─────────────────────────────────────────────────────────
269
270export type ConfigField = { field: string; alias: string; kind: 'text' | 'number'; choices?: string[]; fallback: string | number; help: string }
271
272/** The manifest's `userConfig` fields, in its order, each with a short alias. */
273export const CONFIG_FIELDS: ConfigField[] = [
274 { field: 'codexAgents', alias: 'agents', kind: 'text', fallback: '', help: 'comma-separated subagent types sent to Codex; * = every type; empty = none' },
275 { field: 'codexSandbox', alias: 'sandbox', kind: 'text', choices: ['read-only', 'workspace-write'], fallback: 'read-only', help: 'workspace-write lets Codex write in the session folder and /tmp; both modes read any file you can' },
276 { field: 'codexModel', alias: 'model', kind: 'text', fallback: '', help: "passed to codex as -m; empty = Codex's own configured model" },
277 { field: 'maxConcurrent', alias: 'max', kind: 'number', fallback: 2, help: 'live Codex runs at once; a call beyond this is refused' },
278 { field: 'timeoutMinutes', alias: 'timeout', kind: 'number', fallback: 20, help: 'a run past this stops; one-shot fallback: 10 minutes max' },
279 { field: 'codexConsent', alias: 'consent', kind: 'text', choices: ['ask', 'allow'], fallback: 'ask', help: 'ask once per session before the first handoff, or pre-approve' },
280 { field: 'codexRunMode', alias: 'mode', kind: 'text', fallback: 'background', help: 'wait or background for codexAgents types, then overrides: advisor=background, Explore=wait (editing runs always wait)' },
281 { field: 'codexFsScope', alias: 'scope', kind: 'text', choices: ['anything', 'project'], fallback: 'anything', help: 'project confines Codex to the session folder (read, and write under workspace-write) and hides /tmp; checked through `codex exec` on Codex 0.162.1, Linux only (on macOS, the Codex source makes /tmp readable and writable, even under read-only), re-check after upgrading' },
282 { field: 'codexWritePaths', alias: 'paths', kind: 'text', fallback: '', help: 'with scope project and workspace-write: red-test-writer=tests;test, impl=src limits a named agent to those project folders; empty = whole project' },
283]
284
285/** A field's stored value as text, its default when unset. */
286export function storedText(raw: unknown, f: ConfigField): string {
287 const v = ((raw ?? {}) as Record<string, unknown>)[f.field]
288 return String(v ?? f.fallback)
289}
290
291export type DraftPlan = { writes: { field: ConfigField; value: string | number }[]; errors: string[] }
292
293/** The pane's draft against what is stored: the writes still to make, and the values that do not fit. */
294export function planDraft(raw: unknown, draft: Record<string, string>): DraftPlan {
295 const plan: DraftPlan = { writes: [], errors: [] }
296 for (const f of CONFIG_FIELDS) {
297 const text = draft[f.field]
298 if (text === undefined) continue
299 const coerced = coerceConfigValue(f, text)
300 if ('error' in coerced) plan.errors.push(coerced.error)
301 else if (String(coerced.value) !== storedText(raw, f)) plan.writes.push({ field: f, value: coerced.value })
302 }
303 return plan
304}
305
306export const CONFIG_USAGE = 'Usage: /duet config [<field> <value>] fields: ' + CONFIG_FIELDS.map(f => `${f.alias}`).join(', ') + ' ("" clears a text field)'
307
308export function findConfigField(name: string): ConfigField | undefined {
309 const n = name.trim().toLowerCase()
310 return CONFIG_FIELDS.find(f => f.field.toLowerCase() === n || f.alias === n)
311}
312
313/** `/duet config` with no field: every option, its stored value or default. */
314export function configText(raw: unknown): string {
315 const o = (raw ?? {}) as Record<string, unknown>
316 const rows = CONFIG_FIELDS.map(f => {
317 const v = o[f.field] ?? f.fallback
318 const shown = v === '' ? '(empty)' : String(v)
319 const capped = f.field === 'timeoutMinutes' && Number(v) * 60_000 > FALLBACK_MAX_MS ? ' (a one-shot fallback run stops at 10 minutes)' : ''
320 return ` ${f.alias.padEnd(8)} ${f.field.padEnd(15)} ${shown}${f.choices ? ` [${f.choices.join(' | ')}]` : ''}${capped}`
321 })
322 return ['duet config', ...rows, CONFIG_USAGE].join('\n')
323}
324
325/** The value text after the field, checked against the field: `{ value }` or `{ error }`. */
326export function coerceConfigValue(f: ConfigField, text: string): { value: string | number } | { error: string } {
327 let t = text.trim()
328 if (/^(""|'')$/.test(t)) t = ''
329 else if (/^(".*"|'.*')$/.test(t)) t = t.slice(1, -1)
330 if (f.kind === 'number') {
331 const n = Number(t)
332 if (t === '' || !Number.isFinite(n) || n < (f.field === 'timeoutMinutes' ? 1 : 0)) return { error: `${f.field} takes a number${f.field === 'timeoutMinutes' ? ' ≥ 1' : ' ≥ 0'}, got "${text.trim()}".` }
333 return { value: f.field === 'maxConcurrent' ? Math.floor(n) : n }
334 }
335 if (f.choices && !f.choices.includes(t)) return { error: `${f.field} takes one of ${f.choices.join(', ')}, got "${t}".` }
336 if (f.field === 'codexRunMode') {
337 const m = parseRunModes(t)
338 if ('error' in m) return { error: m.error }
339 }
340 if (f.field === 'codexWritePaths') {
341 const w = parseWritePaths(t)
342 if (typeof w.error === 'string') return { error: w.error }
343 }
344 return { value: t }
345}
346
347// ── Codex's model catalog (`codex debug models`) ─────────────────────────
348
349export const MODELS_ARGV = ['codex', 'debug', 'models']
350/** Picker rows: hotkeys 1–9; 0 is "(empty)". */
351export const MAX_PICKS = 9
352
353/** The catalog's JSON as models, in Codex's order; `{ error }` when it is not the catalog. */
354export function parseModelCatalog(json: string): { models: CodexModel[] } | { error: string } {
355 let data: unknown
356 try {
357 data = JSON.parse(json)
358 } catch {
359 return { error: 'codex debug models did not print JSON.' }
360 }
361 const list = (data as { models?: unknown })?.models
362 if (!Array.isArray(list)) return { error: 'codex debug models printed no model list.' }
363 const models = list
364 .filter((m): m is Record<string, unknown> => typeof m?.slug === 'string' && m.slug !== '')
365 .map(m => ({
366 slug: m.slug as string,
367 name: typeof m.display_name === 'string' ? m.display_name : (m.slug as string),
368 description: typeof m.description === 'string' ? m.description : '',
369 isHidden: m.visibility === 'hide',
370 }))
371 return models.length ? { models } : { error: 'codex debug models listed no models.' }
372}
373
374/** The models the picker offers: the listed ones, hidden ones left out. */
375export function pickable(models: CodexModel[]): CodexModel[] {
376 return models.filter(m => !m.isHidden).slice(0, MAX_PICKS)
377}
378
379function distance(a: string, b: string): number {
380 let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
381 for (let i = 1; i <= a.length; i++) {
382 const row = [i]
383 for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j]! + 1, row[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
384 prev = row
385 }
386 return prev[b.length]!
387}
388
389/** '' when `slug` is empty, in the catalog, or there is no catalog; else why it may not run, with the nearest slug. */
390export function modelWarning(slug: string, models: CodexModel[]): string {
391 const s = slug.trim()
392 if (s === '' || models.length === 0 || models.some(m => m.slug === s)) return ''
393 const norm = (t: string) => t.toLowerCase().replace(/[^a-z0-9]/g, '')
394 const near = [...models].sort((a, b) => distance(norm(s), norm(a.slug)) - distance(norm(s), norm(b.slug)))[0]!
395 const close = norm(near.slug) === norm(s) || distance(norm(s), norm(near.slug)) <= 3
396 return `"${s}" is not in Codex's model list${close ? `; did you mean ${near.slug}?` : '.'}`
397}
398
399/** `/duet config models`: the catalog as text. */
400export function modelsText(models: CodexModel[], current: string): string {
401 const rows = models.map(m => ` ${m.slug === current ? '✓' : ' '} ${m.slug.padEnd(26)} ${m.name}${m.isHidden ? ' (hidden)' : ''}${m.description ? ` · ${m.description}` : ''}`)
402 return ['Codex models (codex debug models)', ...rows, `Set one: /duet config model <slug> ("" uses Codex's default${current === '' ? ', the current setting' : ''})`].join('\n')
403}
404
405// ── Custom agents (.claude/agents, by Claude's discovery rules) ──────────
406
407export type AgentDef = {
408 name: string
409 description: string
410 /** The `tools` value as a list; null when there is no `tools` key (every tool). */
411 tools: string[] | null
412 /** The `disallowedTools` value as a list. */
413 disallowed?: string[]
414 /** `tools` or `disallowedTools` is in a form duet doesn't read (a block scalar, an unclosed `[`, a continued line): read-only. */
415 isToolsUnread?: boolean
416 body: string
417}
418
419const LIST_KEYS = ['tools', 'disallowedTools']
420
421/**
422 * A frontmatter value without its ` # comment`. As in YAML, a `#` stays inside a quoted
423 * value or a quoted `[a, b]` item; in an unquoted value, quotes are just characters.
424 */
425function uncommented(value: string): string {
426 const v = value.trim()
427 const isFlow = v.startsWith('[')
428 let quote = ''
429 let isItemStart = true
430 for (let i = 0; i < v.length; i++) {
431 const ch = v[i]!
432 if (quote) {
433 if (quote === '"' && ch === '\\') i++
434 else if (ch === quote) quote = ''
435 continue
436 }
437 if (ch === '#' && (i === 0 || /\s/.test(v[i - 1]!))) return v.slice(0, i).trim()
438 if ((ch === '"' || ch === "'") && isItemStart) quote = ch
439 if (!/\s/.test(ch)) isItemStart = isFlow && (ch === '[' || ch === ',')
440 }
441 return v
442}
443
444/**
445 * A list value duet reads as written: no block scalar, mapping, anchor, alias or tag, a `[`
446 * closes on its line, and a quoted value ends at its closing quote.
447 */
448const isReadable = (value: string): boolean =>
449 !/^([>|{&!]|\*\S)/.test(value) &&
450 (!value.startsWith('[') || value.endsWith(']')) &&
451 (!/^["']/.test(value) || (value.length > 1 && value.endsWith(value[0]!)))
452
453/**
454 * A frontmatter list (`a, b`, `[a, b]`, quoted or not) as tool names; `[]` or an
455 * empty value is an empty list. A permission specifier names its whole tool:
456 * `Write(src/**)` is `Write`, and a comma inside the parentheses doesn't split.
457 */
458function listOf(raw: string): string[] {
459 const parts: string[] = []
460 let part = ''
461 let depth = 0
462 for (const ch of raw.replace(/^\[|\]$/g, '')) {
463 if (ch === ',' && depth === 0) {
464 parts.push(part)
465 part = ''
466 continue
467 }
468 if (ch === '(') depth++
469 else if (ch === ')') depth = Math.max(0, depth - 1)
470 part += ch
471 }
472 parts.push(part)
473 return parts.map(s => s.trim().replace(/^["']|["']$/g, '').replace(/\s*\(.*$/, '').trim()).filter(Boolean)
474}
475
476/**
477 * Frontmatter (`name`, `description`, `tools`, `disallowedTools`) and body of an
478 * agent file; null when it has none. `tools` and `disallowedTools` may be one line
479 * (`a, b` or `[a, b]`) or a YAML block list of `- a` lines; any other form is
480 * marked unread, so the agent runs read-only rather than on a guess.
481 */
482export function parseAgentFile(text: string): AgentDef | null {
483 const m = text.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
484 if (!m) return null
485 const fields: Record<string, string> = {}
486 const blocks: Record<string, string[]> = {}
487 let isToolsUnread = false
488 let key = ''
489 for (const line of (m[1] ?? '').split(/\r?\n/)) {
490 const kv = line.match(/^["']?([A-Za-z_-]+)["']?\s*:\s*(.*)$/)
491 if (kv) {
492 key = kv[1]!
493 fields[key] = (kv[2] ?? '').trim()
494 delete blocks[key]
495 continue
496 }
497 const rest = line.trim()
498 if (!key || rest === '' || rest.startsWith('#')) continue
499 if (!LIST_KEYS.includes(key)) {
500 // An indented line goes on with a plain value, as YAML folds it: text below an empty `description:`, or a value
501 // that wraps. A `>` or `|` block keeps its indicator as its value.
502 if (/^\s/.test(line) && !/^[>|]/.test(fields[key]!)) fields[key] = `${fields[key]} ${rest}`.trim()
503 continue
504 }
505 // A `- item` line, indented or not, under an empty value is a block list; any other line continues the value.
506 const item = rest.match(/^-(?:\s+(.*))?$/)
507 if (item && uncommented(fields[key] ?? '') === '') (blocks[key] ??= []).push(uncommented(item[1] ?? ''))
508 else isToolsUnread = true
509 }
510 const listField = (name: string): string[] | null => {
511 const value = fields[name]
512 if (value === undefined) return null
513 const values = [uncommented(value), ...(blocks[name] ?? [])]
514 if (!values.every(isReadable)) isToolsUnread = true
515 return values.flatMap(listOf)
516 }
517 // An absent `tools` key means every tool; a present but empty one (`tools: []`) means none.
518 const tools = listField('tools')
519 const disallowed = listField('disallowedTools') ?? []
520 return {
521 name: fields.name ?? '',
522 description: fields.description ?? '',
523 tools,
524 disallowed,
525 ...(isToolsUnread ? { isToolsUnread } : {}),
526 body: (m[2] ?? '').trim(),
527 }
528}
529
530/** A frontmatter scalar as YAML reads it: without its ` # comment` and one pair of surrounding quotes. */
531function scalarOf(value: string): string {
532 return uncommented(value).replace(/^(["'])(.*)\1$/, '$2').trim()
533}
534
535/** A `name` Claude skips its file for: one that starts with `-`, holds a `:` or is over 256 characters. */
536const isSkippedName = (name: string): boolean => name.startsWith('-') || name.includes(':') || name.length > 256
537
538/**
539 * The definition Claude would use for a subagent type, from the file texts of one `.claude/agents` folder.
540 * Which folder wins is the caller's search. Identity comes only from the `name` field; a file with no `name`
541 * or no `description`, or a `name` that starts with `-`, holds a `:` or is over 256 characters, is skipped as
542 * Claude skips it. Two matches that disagree on editing (Claude's pick between them isn't documented) give
543 * the one that can't edit. No match is null.
544 */
545export function pickAgent(texts: string[], type: string): AgentDef | null {
546 const defs: AgentDef[] = []
547 for (const text of texts) {
548 const def = parseAgentFile(text)
549 if (!def) continue
550 const name = scalarOf(def.name)
551 if (name !== type || isSkippedName(name) || scalarOf(def.description) === '') continue
552 defs.push({ ...def, name })
553 }
554 return defs.find(def => !canEdit(def, type)) ?? defs[0] ?? null
555}
556
557/** A filesystem-safe folder name for a session id. */
558export function sessionFolderOf(sessionId: string): string {
559 return sessionId.replace(/[^\w.-]/g, '_').slice(0, 80) || 'unknown'
560}
561
562/**
563 * Whether an agent may edit. A definition: its `tools` (none listed means
564 * every tool) minus `disallowedTools` must keep an edit tool. No definition
565 * found: only Claude's `general-purpose` agent edits; anything unresolved
566 * (a built-in like Explore or Plan, a plugin's agent, a file duet can't find)
567 * is read-only, so a lookup miss never raises the sandbox. So is a definition
568 * whose `tools` or `disallowedTools` duet couldn't read.
569 */
570export function canEdit(def: AgentDef | null, type: string = DEFAULT_TYPE): boolean {
571 if (!def) return type === DEFAULT_TYPE
572 if (def.isToolsUnread) return false
573 const allowed = def.tools === null || def.tools.includes('*') ? EDIT_TOOLS : EDIT_TOOLS.filter(t => def.tools!.includes(t))
574 return allowed.some(t => !(def.disallowed ?? []).includes(t))
575}
576
577/**
578 * An agent can only lower the sandbox, never raise it. A search cut short by duet's limits runs read-only:
579 * a definition past them, which Claude reads, may forbid the edits that duet's pick allows.
580 */
581export function effectiveSandbox(configured: Sandbox, def: AgentDef | null, type: string = DEFAULT_TYPE, isSearchCut = false): Sandbox {
582 return configured === 'workspace-write' && !isSearchCut && canEdit(def, type) ? 'workspace-write' : 'read-only'
583}
584
585// ── Handoff ──────────────────────────────────────────────────────────────
586
587/** A short, stable hash of the task text: the envelope's spec version. */
588export function specVersion(text: string): string {
589 let h = 0x811c9dc5
590 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193) >>> 0
591 return h.toString(16).padStart(8, '0').slice(0, 7)
592}
593
594/**
595 * The consent question. What Codex can reach was measured with Codex 0.162.1 on
596 * Linux (SPEC, Change 0.2.4): both sandboxes read any file the user can read;
597 * read-only writes nothing; workspace-write writes in the project and /tmp.
598 */
599export function consentText(cwd: string, sandbox: Sandbox): string {
600 const writes =
601 sandbox === 'workspace-write'
602 ? `Agents that edit can write inside ${cwd} and in temporary folders such as /tmp.`
603 : 'It runs read-only and writes nothing.'
604 return `Send this task to Codex (OpenAI)? Codex can read any file your user account can read, not only ${cwd}, and sends what it reads to OpenAI under your Codex sign-in. ${writes} Allow for the rest of this session?`
605}
606
607/**
608 * Why consent wasn't given when `$.ui.ask` rejected. On Esc (or a hook denying the question) it rejects with
609 * `$.ui.ask: no answer (…)`, and the next call asks again; under `claude -p` there is nobody to ask at all.
610 */
611export function consentRefusal(err: unknown): string {
612 const message = String((err as { message?: unknown } | undefined)?.message ?? err)
613 if (message.includes('$.ui.ask: no answer')) return 'the consent question was dismissed; it is asked again next time'
614 return 'nobody could be asked to allow sending this repo to Codex (set duet codexConsent to allow for headless runs)'
615}
616
617/** Rules the handoff asks Codex to follow. A request to the model: only the sandbox enforces anything. */
618export const PROHIBITED = [
619 'no git commits, pushes, merges, rebases, resets or branch changes',
620 'no package installs or other network access',
621 'no reading or writing outside the allowed scope',
622 'no deleting files you did not create for this task',
623] as const
624
625export type Handoff = { prompt: string; envelope: Envelope }
626
627export function buildHandoff(args: {
628 id: string
629 description: string
630 prompt: string
631 base: string
632 cwd: string
633 sandbox: Sandbox
634 def: AgentDef | null
635 subagentType: string
636}): Handoff {
637 const envelope: Envelope = {
638 specVersion: specVersion(args.prompt),
639 base: args.base,
640 candidate: '',
641 sandbox: args.sandbox,
642 scope: args.cwd,
643 role: args.def ? args.subagentType : '',
644 }
645 const lines = [
646 '# Handoff from Claude Code (duet)',
647 '',
648 `- Task ID: ${args.id}`,
649 `- Spec version: ${envelope.specVersion}`,
650 `- Base commit: ${args.base}`,
651 `- Allowed scope: files under ${args.cwd}`,
652 `- Sandbox: ${args.sandbox}${args.sandbox === 'read-only' ? ' (investigate and answer; do not edit files)' : ''}`,
653 `- Prohibited: ${PROHIBITED.join('; ')}`,
654 '- Required output: a complete final answer to the task below, in plain text. If you changed files, end with a list of them. Say plainly what you verified and what you could not.',
655 '',
656 ]
657 if (args.def?.body) lines.push(`## Role instructions (${args.subagentType})`, '', args.def.body, '')
658 lines.push(`## Task: ${args.description || '(no description)'}`, '', args.prompt)
659 return { prompt: lines.join('\n'), envelope }
660}
661
662// ── Codex process ────────────────────────────────────────────────────────
663
664const NEVER_ASK = ['-c', 'approval_policy="never"'] as const
665
666/** True when `path` lies below `root` (both real, absolute paths): never the root itself, nor a sibling sharing its prefix. */
667export function isInside(root: string, path: string): boolean {
668 const base = root.replace(/[/\\]+$/, '')
669 return path.length > base.length + 1 && (path.startsWith(`${base}/`) || path.startsWith(`${base}\\`))
670}
671
672/** The folders Codex keeps read-only inside a writable project (probed on 0.162.1). */
673const PROTECTED = new Set(['.git', '.codex', '.agents'])
674
675/** True when `path` is, or lies below, a `.git`, `.codex` or `.agents` folder anywhere under `root`: a write folder there would undo Codex's own protection. */
676export function isProtectedPath(root: string, path: string): boolean {
677 return isInside(root, path) && path.slice(root.replace(/[/\\]+$/, '').length + 1).split(/[/\\]+/).some(part => PROTECTED.has(part))
678}
679
680/** PATH's absolute folders, in order (`:`-separated; Windows' `;` PATH isn't handled). */
681export function pathDirs(path: string): string[] {
682 return path.split(':').map(d => d.replace(/\/+$/, '')).filter(d => d.startsWith('/'))
683}
684
685/** The folder holding `path`. */
686export function parentOf(path: string): string {
687 return path.replace(/[/\\][^/\\]*$/, '')
688}
689
690/** A TOML basic string: JSON's escapes for `\`, `"` and control characters are TOML's. */
691function tomlString(s: string): string {
692 return JSON.stringify(s)
693}
694
695/**
696 * The permission profile's name. Codex merges a same-named profile from any loaded config into it,
697 * a trusted repository's `.codex/config.toml` included, so each run adds a nonce no config can know.
698 */
699function profileName(nonce = ''): string {
700 const suffix = nonce.replace(/[^A-Za-z0-9]/g, '')
701 return suffix ? `duet-mod-fs-scope-${suffix}` : 'duet-mod-fs-scope'
702}
703
704/** An absolute path: `/…`, `\…` or a drive letter. */
705const ABSOLUTE = /^([/\\]|[A-Za-z]:)/
706
707/** A project-relative folder as a path under `cwd`; null when it is absolute, climbs out of the project, or is the project itself. */
708export function folderUnder(cwd: string, folder: string): string | null {
709 if (ABSOLUTE.test(folder)) return null
710 const parts: string[] = []
711 for (const part of folder.split(/[/\\]+/)) {
712 if (part === '..') {
713 if (!parts.pop()) return null
714 } else if (part !== '' && part !== '.') parts.push(part)
715 }
716 return parts.length ? `${cwd}/${parts.join('/')}` : null
717}
718
719/** `path` made absolute: kept when it is, else resolved against `cwd`, with `.` and `..` folded and never above `/`. */
720export function resolveFrom(cwd: string, path: string): string {
721 if (ABSOLUTE.test(path)) return path
722 const parts = cwd.split('/').filter(Boolean)
723 for (const part of path.split(/[/\\]+/)) {
724 if (part === '..') parts.pop()
725 else if (part !== '' && part !== '.') parts.push(part)
726 }
727 return `/${parts.join('/')}`
728}
729
730/**
731 * A run's `codexFsScope` inputs: the scope, the home folder Codex is installed under, the folders its
732 * agent may write, and folders outside the project it may read (git's own folders in a linked worktree).
733 */
734type FsProfile = { fsScope?: FsScope; home?: string; writePaths?: string[]; reads?: string[]; nonce?: string }
735
736/**
737 * The two `-c` overrides that make a `duet` permission profile the default (SPEC, Change 0.2.6): Codex's
738 * built-in minimal set and the project are readable, nothing else is, and `/tmp` and files outside
739 * are not mounted. Under workspace-write the project is writable, or only the `writePaths` folders
740 * inside it when any are given; a folder outside the project is dropped. Codex's install folder is
741 * read-only because the sandbox needs it to re-run `codex`.
742 */
743function profileArgs(o: { sandbox: Sandbox; cwd: string } & Omit<FsProfile, 'fsScope'>): string[] {
744 // Read-only writes nothing, so its writePaths are ignored rather than allowed to widen it. Any list,
745 // even an empty one, narrows: only an agent with none gets the whole project.
746 const isWritable = o.sandbox === 'workspace-write'
747 const named = isWritable ? o.writePaths ?? [] : []
748 const entries: [string, 'read' | 'write'][] = [[':minimal', 'read'], [o.cwd, isWritable && o.writePaths === undefined ? 'write' : 'read']]
749 for (const folder of new Set(named.flatMap(f => folderUnder(o.cwd, f) ?? []))) if (!isProtectedPath(o.cwd, folder)) entries.push([folder, 'write'])
750 // A read outside the project only: never the project or inside it, and never a folder holding the project or the home folder.
751 const covers = (folder: string, path: string) => folder.replace(/[/\\]+$/, '') === path.replace(/[/\\]+$/, '') || isInside(folder, path)
752 const isNarrow = (path: string) => !covers(o.cwd, path) && !covers(path, o.cwd) && !(o.home && covers(path, o.home))
753 for (const path of new Set(o.reads ?? [])) if (ABSOLUTE.test(path) && isNarrow(path)) entries.push([path, 'read'])
754 if (o.home) entries.push([`${o.home}/.codex/packages`, 'read'])
755 // TOML refuses a repeated key (CODEX_HOME=$HOME/.codex repeats ~/.codex/packages), so each path goes in once, as first given.
756 const unique = new Map<string, 'read' | 'write'>()
757 for (const [path, access] of entries) {
758 const key = path.replace(/\/+/g, '/').replace(/(.)\/$/, '$1')
759 if (!unique.has(key)) unique.set(key, access)
760 }
761 const table = [...unique].map(([path, access]) => `${tomlString(path)}="${access}"`).join(',')
762 const name = profileName(o.nonce)
763 return ['-c', `default_permissions="${name}"`, '-c', `permissions.${name}.filesystem={${table}}`]
764}
765
766/**
767 * `codex exec` for a routed subagent; the prompt goes on stdin (`-`). Under project scope the
768 * sandbox is set with `-c sandbox_mode`, never `-s`: Codex 0.162.1 ignores the permission profile
769 * under `-s` (SPEC, Change 0.2.6).
770 */
771export function codexArgv(o: { sandbox: Sandbox; model: string; cwd: string } & FsProfile): string[] {
772 const sandbox: Sandbox = o.sandbox === 'workspace-write' ? 'workspace-write' : 'read-only'
773 const isScoped = o.fsScope === 'project'
774 const mode = isScoped ? ['-c', `sandbox_mode="${sandbox}"`] : ['-s', sandbox]
775 const profile = isScoped ? profileArgs({ sandbox, cwd: o.cwd, home: o.home, writePaths: o.writePaths, reads: o.reads, nonce: o.nonce }) : []
776 return ['codex', 'exec', '--json', '--skip-git-repo-check', ...mode, ...NEVER_ASK, ...profile, '-C', o.cwd, ...(o.model ? ['-m', o.model] : []), '-']
777}
778
779export type ReviewTarget = { kind: 'uncommitted' } | { kind: 'base'; ref: string } | { kind: 'commit'; ref: string }
780
781/** `/duet review` args → a target; null when they don't parse. */
782export function parseReviewArgs(args: string): ReviewTarget | null {
783 const words = args.trim().split(/\s+/).filter(Boolean)
784 if (words.length === 0) return { kind: 'uncommitted' }
785 const [flag, ref] = words
786 const safe = ref !== undefined && /^[\w./@^~-]+$/.test(ref) && !ref.startsWith('-')
787 if (flag === '--base' && safe && words.length === 2) return { kind: 'base', ref }
788 if (flag === '--commit' && safe && words.length === 2) return { kind: 'commit', ref }
789 return null
790}
791
792/**
793 * `codex exec review`, always read-only and never asking. A target
794 * (`--uncommitted`, `--base`, `--commit`) and a custom prompt can't be combined
795 * (Codex exits 2), so the review runs on Codex's own review instructions.
796 */
797export function reviewArgv(target: ReviewTarget, model: string, scope?: { cwd: string } & FsProfile): string[] {
798 const which = target.kind === 'uncommitted' ? ['--uncommitted'] : [`--${target.kind}`, target.ref]
799 const profile = scope?.fsScope === 'project' ? profileArgs({ sandbox: 'read-only', cwd: scope.cwd, home: scope.home, reads: scope.reads, nonce: scope.nonce }) : []
800 return ['codex', 'exec', 'review', '--json', '--skip-git-repo-check', '-c', 'sandbox_mode="read-only"', ...NEVER_ASK, ...profile, ...which, ...(model ? ['-m', model] : [])]
801}
802
803/**
804 * Claude Code's credential and provider variables, blanked in Codex's
805 * environment. env is laid over the host's, which can't be listed, so the
806 * names are fixed (SPEC, Change 0.2.5). Blanking sets '' and can't unset, and
807 * '' in a path or profile name changes what a tool does (`CLAUDE_CONFIG_DIR`
808 * becomes an empty config dir), so only secrets and provider switches go here,
809 * never a path, directory or profile name. The one exception is the messaging
810 * socket, where the empty path is the point: Codex gets no inbox to post to. Never add what Codex itself needs:
811 * `OPENAI_API_KEY`, `CODEX_HOME`, `PATH`, `HOME`, the proxy variables.
812 */
813export const BLANKED_ENV = [
814 // Anthropic API, and Claude Code's own sign-in state
815 'ANTHROPIC_API_KEY',
816 'ANTHROPIC_AUTH_TOKEN',
817 'ANTHROPIC_BASE_URL',
818 'ANTHROPIC_CUSTOM_HEADERS',
819 'CLAUDE_CODE_OAUTH_TOKEN',
820 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
821 'CLAUDE_CODE_SESSION_ACCESS_TOKEN',
822 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
823 'CLAUDE_CODE_MESSAGING_TOKEN',
824 // A path, blanked on purpose: on macOS and Linux the token is optional, and Codex is a child Claude Code trusts
825 'CLAUDE_CODE_MESSAGING_SOCKET',
826 // Workload Identity Federation: the identity provider's JWT itself
827 'ANTHROPIC_IDENTITY_TOKEN',
828 // Bedrock, and Bedrock Mantle
829 'CLAUDE_CODE_USE_BEDROCK',
830 'ANTHROPIC_BEDROCK_BASE_URL',
831 'AWS_BEARER_TOKEN_BEDROCK',
832 'CLAUDE_CODE_USE_MANTLE',
833 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL',
834 // Claude Platform on AWS
835 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
836 'ANTHROPIC_AWS_API_KEY',
837 'ANTHROPIC_AWS_BASE_URL',
838 'ANTHROPIC_AWS_WORKSPACE_ID',
839 // AWS credentials, and the container credential provider's token (ECS, EKS Pod Identity)
840 'AWS_ACCESS_KEY_ID',
841 'AWS_SECRET_ACCESS_KEY',
842 'AWS_SESSION_TOKEN',
843 'AWS_CONTAINER_AUTHORIZATION_TOKEN',
844 // Vertex
845 'CLAUDE_CODE_USE_VERTEX',
846 'ANTHROPIC_VERTEX_PROJECT_ID',
847 'ANTHROPIC_VERTEX_BASE_URL',
848 // Foundry
849 'CLAUDE_CODE_USE_FOUNDRY',
850 'ANTHROPIC_FOUNDRY_API_KEY',
851 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
852 'ANTHROPIC_FOUNDRY_BASE_URL',
853 'ANTHROPIC_FOUNDRY_RESOURCE',
854 // Foundry's Entra path: the Azure SDK's default credential chain (names from the Azure SDK docs, not Claude's)
855 'AZURE_CLIENT_ID',
856 'AZURE_TENANT_ID',
857 'AZURE_CLIENT_SECRET',
858 'AZURE_CLIENT_CERTIFICATE_PASSWORD',
859 'AZURE_PASSWORD',
860 // Azure managed identity (App Service, Functions): the token endpoint's secret header
861 'IDENTITY_HEADER',
862] as const
863export function childEnv(): Record<string, string> {
864 return Object.fromEntries(BLANKED_ENV.map(k => [k, '']))
865}
866
867export type CodexLaunch = { argv: string[]; env: Record<string, string>; input?: string }
868
869/**
870 * How Codex starts: argv, the blanked env, and any stdin. `$.process.spawn`
871 * takes it whole, and the `$.process.run` fallback takes its parts, so the
872 * env is decided here once; tests can't reach spawn itself (it can't be stubbed).
873 */
874export function codexLaunch(argv: string[], input: string | undefined): CodexLaunch {
875 return { argv, env: childEnv(), ...(input !== undefined ? { input } : {}) }
876}
877
878// ── Codex JSONL ──────────────────────────────────────────────────────────
879
880export type Run = {
881 threadId: string
882 text: string
883 warnings: string[]
884 usage: { input: number; cached: number; output: number }
885 failure: string
886 isDone: boolean
887}
888
889export const EMPTY_RUN: Run = { threadId: '', text: '', warnings: [], usage: { input: 0, cached: 0, output: 0 }, failure: '', isDone: false }
890
891/** One line of `codex exec --json` folded into the run. Unknown lines leave it as it was. */
892export function foldEvent(run: Run, line: string): Run {
893 let ev: Record<string, any>
894 try {
895 ev = JSON.parse(line)
896 } catch {
897 return run
898 }
899 if (!ev || typeof ev !== 'object') return run
900 switch (ev.type) {
901 case 'thread.started':
902 return { ...run, threadId: String(ev.thread_id ?? '') }
903 case 'item.completed': {
904 const item = ev.item ?? {}
905 if (item.type === 'agent_message') return { ...run, text: String(item.text ?? '') }
906 if (item.type === 'error') return { ...run, warnings: [...run.warnings, String(item.message ?? '')] }
907 // On Linux, without Codex's install folder the sandbox (bwrap) can't run Codex's own binary: every command fails, and the answer is hollow.
908 // Only a failed command whose output has a line starting with bwrap's message counts; a diff or a file quoting it does not.
909 const isFailed = item.type === 'command_execution' && item.exit_code !== 0
910 const unrunnable = isFailed ? String(item.aggregated_output ?? '').match(/^bwrap: execvp (\S+): No such file or directory$/m) : null
911 if (unrunnable && !run.failure) return { ...run, failure: `Codex could not run ${unrunnable[1]} inside its sandbox: under codexFsScope project its install folder must be readable, and duet grants only the folder of the codex on PATH and $CODEX_HOME/packages.` }
912 return run
913 }
914 case 'turn.completed': {
915 const u = ev.usage ?? {}
916 return {
917 ...run,
918 isDone: true,
919 usage: {
920 input: run.usage.input + (Number(u.input_tokens) || 0),
921 cached: run.usage.cached + (Number(u.cached_input_tokens) || 0),
922 output: run.usage.output + (Number(u.output_tokens) || 0) + (Number(u.reasoning_output_tokens) || 0),
923 },
924 }
925 }
926 case 'turn.failed':
927 return { ...run, failure: String(ev.error?.message ?? 'the Codex turn failed') }
928 case 'error':
929 return { ...run, failure: String(ev.message ?? 'Codex reported an error') }
930 default:
931 return run
932 }
933}
934
935/** Splits streamed text into whole lines, keeping the unfinished tail. */
936export function splitLines(carry: string, chunk: string): { lines: string[]; carry: string } {
937 const parts = (carry + chunk).split('\n')
938 const tail = parts.pop() ?? ''
939 return { lines: parts.filter(l => l.trim()), carry: tail }
940}
941
942/** Every line of a whole stdout, folded. */
943export function foldAll(stdout: string): Run {
944 return stdout.split('\n').filter(l => l.trim()).reduce(foldEvent, EMPTY_RUN)
945}
946
947/** Why a finished run failed, or '' when it succeeded. */
948export function failureOf(run: Run, exitCode: number | null, stderr: string): string {
949 if (run.failure) return run.failure
950 if (exitCode !== 0) {
951 if (/not logged in|login|unauthori[sz]ed|401/i.test(stderr)) return 'codex: not signed in. Run `codex login`, then retry.'
952 const first = stderr.split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('Reading additional input'))[0]
953 return `codex exited with ${exitCode ?? 'a signal'}${first ? `: ${first}` : ''}`
954 }
955 if (!run.text) return 'codex finished without an answer'
956 return ''
957}
958
959export function tokensOf(run: Run): number {
960 return run.usage.input + run.usage.output
961}
962
963// ── The Agent result Claude reads ────────────────────────────────────────
964
965export const ONE_SHOT = 'one-shot run: start a new duet task to follow up'
966
967/** The result's last line; `dropped` names the codexWritePaths folders Codex could not write. */
968export function footerOf(t: { id: string; model: string; sandbox: Sandbox; base: string; candidate: string; dropped?: string[] }): string {
969 const dropped = t.dropped?.length ? ` · not writable: ${t.dropped.join(', ')} (missing, outside the project, or in .git, .codex or .agents)` : ''
970 return `[duet ${t.id} · codex${t.model ? ` ${t.model}` : ''} · sandbox ${t.sandbox} · base ${t.base} · ${t.candidate || 'no files changed by this run'}${dropped} · ${ONE_SHOT}]`
971}
972
973/** The Agent tool's `completed` record, answered for Codex's run. */
974export function agentResult(a: { id: string; prompt: string; text: string; footer: string; durationMs: number; run: Run; model: string }) {
975 return {
976 status: 'completed' as const,
977 agentId: `duet-${a.id}`,
978 agentType: 'codex',
979 prompt: a.prompt,
980 content: [{ type: 'text' as const, text: `${a.text}\n\n${a.footer}` }],
981 resolvedModel: a.model ? `codex:${a.model}` : 'codex',
982 totalToolUseCount: 0,
983 totalDurationMs: Math.max(0, Math.round(a.durationMs)),
984 totalTokens: tokensOf(a.run),
985 canContinueAgent: false,
986 usage: {
987 input_tokens: a.run.usage.input,
988 output_tokens: a.run.usage.output,
989 cache_creation_input_tokens: null,
990 cache_read_input_tokens: a.run.usage.cached,
991 server_tool_use: null,
992 service_tier: null,
993 cache_creation: null,
994 },
995 }
996}
997
998/** The Agent tool's `async_launched` record: a background Codex run that answers later. */
999export function launchedRecord(a: { id: string; description: string; prompt: string; outputFile: string; model: string }) {
1000 return {
1001 status: 'async_launched' as const,
1002 agentId: `duet-${a.id}`,
1003 description: a.description,
1004 prompt: a.prompt,
1005 outputFile: a.outputFile,
1006 canReadOutputFile: true,
1007 canContinueAgent: false,
1008 resolvedModel: a.model ? `codex:${a.model}` : 'codex',
1009 }
1010}
1011
1012/** The message a background run delivers to the main session when it ends. */
1013export function deliveryText(a: { id: string; label: string; model: string; sandbox: Sandbox; durationMs: number; tokens: number; text: string; footer: string; failure: string; searchNote?: string }): string {
1014 const head = `Background agent duet-${a.id} (${a.label})`
1015 // A failure has no footer to carry the agent search's cut-short note, so it goes on a line of its own.
1016 if (a.failure) return `${head} failed on Codex: ${a.failure}. It did not run on Claude either.${a.searchNote ? `\n${a.searchNote}` : ''}`
1017 return `${head} finished on Codex · ${a.model || 'default model'} · ${a.sandbox} · ${duration(a.durationMs)} · ${compact(a.tokens)} tok\n\n${a.text}\n\n${a.footer}`
1018}
1019
1020export const READ_ONLY_CANDIDATE = 'read-only run: no files changed'
1021
1022/**
1023 * `git status --porcelain` lines as status + path. The leading space is part
1024 * of the status column (` M a.ts`), so lines must not be trimmed at the
1025 * start. A rename (`R old -> new`) is its new path.
1026 */
1027export function pathsOf(porcelain: string): { status: string; path: string }[] {
1028 return porcelain
1029 .split('\n')
1030 .map(l => l.replace(/\r$/, ''))
1031 .filter(l => l.length > 3)
1032 .map(l => ({ status: l.slice(0, 2), path: l.slice(3).split(' -> ').pop()!.replace(/^"|"$/g, '') }))
1033}
1034
1035/** Paths whose content can be hashed (not deleted). */
1036export function hashablePaths(porcelain: string): string[] {
1037 return pathsOf(porcelain)
1038 .filter(p => !p.status.includes('D'))
1039 .map(p => p.path)
1040}
1041
1042/**
1043 * A snapshot of the dirty tree: one `status<TAB>path<TAB>hash` line per path,
1044 * the hash being the file's content hash (`git hash-object`), so a file that
1045 * was already modified and is modified again still counts as changed.
1046 */
1047export function snapshotOf(porcelain: string, hashes: Readonly<Record<string, string>>): string {
1048 return pathsOf(porcelain)
1049 .map(p => `${p.status}\t${p.path}\t${hashes[p.path] ?? ''}`)
1050 .join('\n')
1051}
1052
1053/** What changed during an editing run: paths whose status or content differs between two snapshots. */
1054export function candidateOf(before: string, after: string): string {
1055 const read = (t: string) =>
1056 new Map(
1057 t
1058 .split('\n')
1059 .filter(Boolean)
1060 .map(l => {
1061 const [status = '', path = '', hash = ''] = l.split('\t')
1062 return [path, `${status}\t${hash}`] as const
1063 }),
1064 )
1065 const a = read(before)
1066 const b = read(after)
1067 const paths = new Set<string>()
1068 for (const [path, state] of b) if (a.get(path) !== state) paths.add(path)
1069 for (const path of a.keys()) if (!b.has(path)) paths.add(path)
1070 if (!paths.size) return 'no files changed by this run'
1071 const list = [...paths].sort()
1072 const shown = list.slice(0, 3).join(', ') + (list.length > 3 ? ` +${list.length - 3} more` : '')
1073 return `files changed during this run: ${list.length} (${shown})`
1074}
1075
1076/** The verdict line of a review, or its first line. */
1077export function verdictOf(text: string): string {
1078 const v = text.split('\n').reverse().find(l => /verdict:/i.test(l))
1079 return (v ?? text.split('\n').find(l => l.trim()) ?? '').trim().replace(/^[*_`\s]+|[*_`\s]+$/g, '')
1080}
1081
1082// ── Reloads and Claude's background runs ─────────────────────────────────
1083
1084export const LOST = 'lost when duet reloaded'
1085
1086/** Running tasks this module did not start were cut off by a reload: marked error, and returned. */
1087export function lostOnReload(tasks: Task[], mine: ReadonlySet<string>, now: number): { tasks: Task[]; lost: Task[] } {
1088 const lost: Task[] = []
1089 const next = tasks.map(t => {
1090 if (t.provider !== 'codex' || t.status !== 'running' || mine.has(t.id)) return t
1091 const l = { ...t, status: 'error' as const, endedAt: now, detail: LOST }
1092 lost.push(l)
1093 return l
1094 })
1095 return { tasks: next, lost }
1096}
1097
1098/** Codex runs in flight of any kind (routed agents and reviews): what the concurrency limit admits against. */
1099export function codexRunning(tasks: Task[]): number {
1100 return tasks.filter(t => t.provider === 'codex' && t.status === 'running').length
1101}
1102
1103/** Why a Codex run was refused at the limit. */
1104export function limitText(running: number, max: number): string {
1105 return `limit: ${running} Codex run${running === 1 ? '' : 's'} already running (maxConcurrent ${max})`
1106}
1107
1108/** The fallback run's ceiling: the engine caps a one-shot process at ten minutes. */
1109export const FALLBACK_MAX_MS = 600_000
1110
1111/** Why a one-shot fallback run failed: a timeout when it ran to its ceiling, else a start failure. */
1112export function fallbackFailure(message: string, elapsedMs: number, capMs: number): string {
1113 if (/ENOENT|not found|No such file/i.test(message)) return 'codex not found on PATH (install the Codex CLI, then `codex login`)'
1114 if (/time(d)? ?out/i.test(message) || elapsedMs >= capMs - 1000) return `timed out after ${Math.round(capMs / 60_000)}m (the fallback runs at most ${Math.round(FALLBACK_MAX_MS / 60_000)} minutes)`
1115 return `codex could not start: ${message.split('\n')[0]}`
1116}
1117
1118/** A running row's note when the fallback's ceiling cuts a longer timeoutMinutes; '' when it changes nothing. */
1119export function fallbackNote(timeoutMs: number): string {
1120 if (timeoutMs <= FALLBACK_MAX_MS) return ''
1121 return `fallback: stops at ${Math.round(FALLBACK_MAX_MS / 60_000)}m (timeoutMinutes ${Math.round(timeoutMs / 60_000)})`
1122}
1123
1124/** Routed Codex agent runs still going (what the status line counts; reviews excluded). */
1125export function liveCount(tasks: Task[]): number {
1126 return tasks.filter(t => t.provider === 'codex' && t.kind === 'agent' && t.status === 'running').length
1127}
1128
1129/** The id of a Claude subagent that answers later (background or teammate), else null. */
1130export function pendingIdOf(result: unknown): string | null {
1131 const r = result as { status?: string; agentId?: string } | undefined
1132 return r && (r.status === 'async_launched' || r.status === 'teammate_spawned') && typeof r.agentId === 'string' && r.agentId ? r.agentId : null
1133}
1134
1135/** Every token a turn counted: input, cache reads and writes, and output. */
1136export function usageTokens(u: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | undefined): number {
1137 if (!u) return 0
1138 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
1139}
1140
1141/** The Claude row a background subagent's first finished turn makes. */
1142export function claudeTaskOf(p: ClaudePending, e: { usage?: Parameters<typeof usageTokens>[0] & { model?: string }; isAborted: boolean; reason?: string }, now: number): Task {
1143 return {
1144 id: `c${p.startedAt}`, provider: 'claude', kind: 'agent', subagentType: p.type, model: e.usage?.model ?? '',
1145 status: !e.isAborted && (e.reason ?? 'answer') === 'answer' ? 'done' : 'error', startedAt: p.startedAt, endedAt: Math.max(p.startedAt, now),
1146 tokens: usageTokens(e.usage), description: p.description, detail: e.isAborted ? 'aborted' : '', envelope: null,
1147 }
1148}
1149
1150// ── Run log ──────────────────────────────────────────────────────────────
1151
1152export type RunLine = {
1153 id: string
1154 at: number
1155 provider: 'codex' | 'claude'
1156 kind: 'agent' | 'review'
1157 type: string
1158 model: string
1159 ms: number
1160 tokens: number
1161 ok: boolean
1162}
1163
1164export function runLineOf(t: Task): RunLine {
1165 return {
1166 id: t.id,
1167 at: t.startedAt,
1168 provider: t.provider,
1169 kind: t.kind,
1170 type: t.subagentType,
1171 model: t.model,
1172 ms: Math.max(0, (t.endedAt ?? t.startedAt) - t.startedAt),
1173 tokens: t.tokens,
1174 ok: t.status === 'done',
1175 }
1176}
1177
1178/** A run log's non-blank lines. */
1179function logLines(text: string): string[] {
1180 return text.split('\n').filter(l => l.trim())
1181}
1182
1183/** The log with one line added, kept to the newest `keep` lines. */
1184export function appendLog(existing: string, line: RunLine, keep = 2000): string {
1185 const lines = logLines(existing)
1186 lines.push(JSON.stringify(line))
1187 return lines.slice(-keep).join('\n') + '\n'
1188}
1189
1190/** Several run logs (the legacy one, each session's) as one: their non-blank lines, so a log with no final newline can't swallow the next one's first. */
1191export function mergeLogs(texts: string[]): string {
1192 const lines = texts.flatMap(logLines)
1193 return lines.length ? lines.join('\n') + '\n' : ''
1194}
1195
1196function median(xs: number[]): number {
1197 if (!xs.length) return 0
1198 const s = [...xs].sort((a, b) => a - b)
1199 const mid = Math.floor(s.length / 2)
1200 return s.length % 2 ? s[mid]! : Math.round((s[mid - 1]! + s[mid]!) / 2)types/index.d.ts 84 lines1// Duet's state contract. Self-contained: no imports.
2
3export type Provider = 'codex' | 'claude'
4export type TaskStatus = 'running' | 'done' | 'error' | 'refused'
5export type Sandbox = 'read-only' | 'workspace-write'
6
7/** What a handoff carried, shown when a row is selected. */
8export type Envelope = {
9 specVersion: string
10 base: string
11 candidate: string
12 sandbox: Sandbox
13 scope: string
14 role: string
15}
16
17/** One delegated task, as the pane draws it. */
18export type Task = {
19 id: string
20 provider: Provider
21 kind: 'agent' | 'review'
22 subagentType: string
23 model: string
24 status: TaskStatus
25 startedAt: number
26 endedAt: number | null
27 tokens: number
28 description: string
29 detail: string
30 envelope: Envelope | null
31}
32
33/** One entry of Codex's model catalog, as the config pane offers it. */
34export type CodexModel = {
35 slug: string
36 name: string
37 description: string
38 isHidden: boolean
39}
40
41/** A Claude subagent that answers later (background or teammate), kept until its first turn ends. */
42export type ClaudePending = {
43 startedAt: number
44 type: string
45 description: string
46}
47
48export type Consent = 'unasked' | 'yes' | 'no'
49export type View = 'tasks' | 'stats'
50
51declare module 'claude-code' {
52 interface PluginState {
53 duet: {
54 tasks: Task[]
55 selected: number
56 view: View
57 consent: Consent
58 statsText: string
59 now: number
60 isDemo: boolean
61 /** /duet config pane: edited values by field name, not yet saved. */
62 configDraft: Record<string, string>
63 /** The pane's last message (saved, refused, a value that does not fit). */
64 configNote: string
65 /** A save is under way; a reload mid-save finishes it at session.start. */
66 configSaving: boolean
67 /** Which config row is highlighted. */
68 configAt: number
69 /** The text field being edited in the config pane, '' when none. */
70 configEditing: string
71 /** Codex's model catalog as last fetched; [] before the first fetch. */
72 configModels: CodexModel[]
73 /** The catalog fetch: 'idle', 'fetching', or why the last one failed. */
74 configModelsState: string
75 /** The model list is open on the model row (e opens it; a pick, t, or moving off closes it). */
76 configPicking: boolean
77 /** When a save that writes began (0: none): the reload it causes reopens and refocuses the pane. */
78 configReopenAt: number
79 /** Claude subagents that answer later, by agentId; measured at their first turn.complete. */
80 claudePending: Record<string, ClaudePending>
81 }
82 }
83}
84