SLOPSHOPPER

duet

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

newpaneguardcommandtoaststatus
v0.2.13MITupdated 2026-10-16bankielewicz/mod-builder/mods/duet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · duet
│ ┃ Duet ✕ › fix the failing auth test and add an audit log call │ ┃ D U E T │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ No Codex tasks yet. Route a subagent type ⏺ Update(src/auth.ts) │ ┃ in /config → duet, or run /duet review. ⎿ Added 2 lines, removed 1 line │ ┃ ────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────── ⎿ 3 pass, 1 fail │ ┃ routes codex ← nothing routed │ ┃ roles advisor waits · reviewer bg ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ read-only · max │ ┃ [ Review diff ] s: Stats j: Next k: Prev ✻ Worked for 42s · done 4:20 PM │ ┃ ctrl+x tab: keys, again: next tab · r r… │ › /duet │ ⎿ duet: Opened. codex ← nothing routed · roles duet:codex-advisor │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Duet
D U E T ────────────────────────────────────────────────────── No Codex tasks yet. Route a subagent type in /config → duet, or run /duet review. ────────────────────────────────────────────────────── routes codex ← nothing routed roles advisor waits · reviewer bg read-only · max [ Review diff ] s: Stats j: Next k: Prev c: Close ctrl+x tab: keys, again: next tab · r review s stats …
Pane · duet-config
DUET CONFIG saved ────────────────────────────────────────────────────── ▸ agents (empty) sandbox read-only [read-only | workspace-write] model (empty) max 2 timeout 20 consent ask [ask | allow] mode background scope anything [anything | project] paths (empty) ────────────────────────────────────────────────────── codexAgents: comma-separated subagent types sent to Co [ Edit ] j: Down k: Up s: Save u: Revert c: Close j/k: move · e: change · s: save · u: revert · c: close
README

Duet

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.

  • Codex roles Claude can pick: duet adds 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.
  • Routing: list subagent types in /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.
  • Wait or background: a read-only Codex run either makes Claude wait for its answer or answers at once and delivers the answer later as a message ("The duet plugin sent a message: Background agent duet-d3 … finished on Codex"). Each role has a default and codexRunMode overrides it. A run that can edit always waits, so Codex and Claude never edit at the same time.
  • Custom agents: a routed custom agent is found as Claude finds it: duet looks in .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.
  • Independent review: /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>.
  • Measurements: every Codex run and every main-loop Claude subagent is logged; /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 ]

Try it

/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 value

Reaching 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"}}]}

Install

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.

Options

OptionDefaultWhat it does
codexAgentsempty (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.
codexSandboxread-onlyread-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.
codexModelempty-m for Codex; empty uses Codex's configured model.
maxConcurrent2Codex runs at once; beyond it, the call is refused with a reason.
timeoutMinutes20A 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.
codexConsentaskask 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).
codexRunModebackgroundwait 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.
codexFsScopeanythinganything 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.
codexWritePathsemptyWith 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.

Safety boundaries

  • Codex always runs with -c approval_policy="never" under its own sandbox. Duet never passes --dangerously-bypass-* or --approve-for-me. Reviews are always read-only.
  • A routed Agent call is answered above Claude's permission check, so Codex's sandbox is the only control on that work. Mods can't read the session's permission mode, so plan mode doesn't tighten Codex. That is why the default is read-only.
  • Only main-loop subagents are routed (depth 1). Named, isolated and nested 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.
  • A Codex run answers once and exits; its result says it can't be messaged (canContinueAgent: false).
  • Read-only runs can go to the background; a run that can edit always waits. A config change reloads duet, and a background run it cuts off is reported as lost when duet reloaded.
  • Each handoff carries the task id, spec version (prompt hash), base commit, allowed scope, prohibited actions (commits, pushes, branch changes, installs, network, outside-scope files) and the required output. For a run that can edit, the footer adds the files whose 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.

What it reaches

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.

ReachDetail
Hookssession.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)
Fileslists ($.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)
Networknone 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 callsnone by the mod directly; Codex usage per routed call and review; each delivered background answer is a Claude turn
Environmentreads 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 hookstool.call{tool=Agent} with .catch (refuses on failure; never falls through)

Development

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.

Source 3 files
hooks/register.tsx 1115 lines
1import { 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}
1115
hooks/duet-logic.ts 1336 lines
1/**
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 lines
1// 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