SLOPSHOPPER

deckhand

Deckhand — the multi-AI toolbar for Claude Code. A band above the prompt with usage gauges, one-click model buttons, Sub5 parallel sub agents in git worktrees…

newpanebandguardcommandprompt
v1.0.0MITupdated 2026-10-09stephen-taipei/deckhand/plugins/deckhand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deckhand
│ ┃ Codex inbox ✕ › fix the failing auth test and add an audit log call │ ┃ This project · last 7 days [ Refresh ] [ All │ ┃ ⏺ Read(src/auth.ts) │ ┃ Failed to read: JSON Parse error: Unexpected ⎿ Read 6 lines │ ┃ identifier "dev" ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /watch-deploy │ ⎿ deckhand: No watches. Usage: /watch-deploy #128, /watch-deploy h │ │ 5h 31% · 7d – · ctx 49% [ O ] [ F ] [ S ] [ H ] [ Sub5 ] [ cL ] [ cS ] [ cA ] [ cR ] [ gF ] [ Recap ] [ ⚙ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h 31% · 7d – · ctx 49% [ O ] [ F ] [ S ] [ H ] [ Sub5 ] [ cL ] [ cS ] [ cA ] [ cR ] [ gF ] [ Recap
Pane · Codex inbox
This project · last 7 days [ Refresh ] [ All projects ] Failed to read: JSON Parse error: Unexpected identifier "dev"
Pane · Delegate records
Delegate runs · last 3 days [ Refresh ] Failed to read: JSON Parse error: Unexpected identifier "dev"
Pane · Deckhand settings
[ General ] [ Delegates ] [ Translate & search ] [ Sub5 ] [ Display language Auto (Claude Code's language) ▾ Band buttons Which parts of the band above the prompt show. [ ☑ Usage ] [ ☑ Model buttons ] [ ☑ Sub5 ] [ ☑ Delegates ] [ Usage warning (%) 80 ⏎ Save [ Close ]
Pane · deckhand-recap
[ Again ] [ Close ]
README

Deckhand — the multi-AI toolbar for Claude Code

Many hands, one deck.

Usage gauges, one-click model switching, parallel sub agents and read-only second opinions from other AI CLIs, in one band above the Claude Code prompt.

Version Python Claude Code License CI

English · 繁體中文 · 简体中文 · 日本語 · 한국어

Install · The band · Delegate buttons · Settings · More tools · Privacy and safety · Sponsor · Changelog

The Deckhand band above the Claude Code prompt: two finished CI watches and their toast, usage gauges, model buttons, Sub5, delegate buttons, Recap and settings

<sub>Screenshots show the Traditional Chinese display language.</sub>

Sponsor Deckhand with USDT (TRC20) — one-time support only.

What Deckhand does

Deckhand is a Claude Code plugin. It adds a band above the prompt, in the terminal and in the desktop app. Claude stays the main agent. From the band you can:

  • see how much of each usage window is left;
  • switch the model with one click;
  • split a task across parallel sub agents in separate git worktrees;
  • ask another AI CLI for a read-only second opinion, which Claude then reviews;
  • get a plain-language recap of where the session stands.

Install

Run these two commands in Claude Code:

/plugin marketplace add stephen-taipei/deckhand
/plugin install deckhand@deckhand

The band appears above the prompt. If it does not, start a new session.

The band

PartWhat it does
Usage gauges5-hour window, per-model weekly windows, weekly all-models window and context window
O F S HSwitch the model to Opus, Fable, Sonnet or Haiku
Sub5Split the current task into up to 5 items and run them in parallel sub agents
cL cS cA cR gFHand one task to another AI CLI, read-only, and let Claude review the answer
RecapExplain the current context plainly, with analogies, in a side pane
⚙Open the settings pane (right end of the band)

Hover a button to see what it does.

Usage gauges

  • 5h: the 5-hour window.
  • Per-model weekly windows that your account has, for example fb for Fable.
  • 7d: the weekly window across all models.
  • ctx: how full the context window is.

The numbers match the usage card in the Claude app (rounded down). When a window crosses the warning threshold, Deckhand shows a toast once.

Model buttons

O Opus · F Fable · S Sonnet · H Haiku

  • Terminal: the button switches the session model directly and keeps your effort level.
  • Desktop app: the app owns the session model there. The button fills /model <name> into the prompt box. You press Enter, and the app's own model menu stays in sync.

Sub5

Sub5 splits the current task into up to 5 independent items. Each item runs in its own sub agent, in its own git worktree, at the same time. When the workers finish, the main agent reviews their work, integrates it, and cleans up the worktrees and branches.

A script (bin/sub5.py) runs the exact git steps. It never forces and never pushes. You can set the workers' model, effort and the maximum number of items in settings.

Delegate buttons

A delegate button hands one task to another AI CLI. That AI works read-only and writes an answer. Claude reviews the answer and integrates what holds up.

ButtonCLIModelEffort
cLCodexGPT-6 Lunamax
cSCodexGPT-6.1 Solmedium
cACodexGPT-6 Astramedium
cRCursor agentGrok 4.7high
gFagyGemini 3.8 Flashhigh

These are the defaults. In settings you can edit every target: label, CLI, model ID, effort, name, and on/off.

  • Read-only, per CLI: Codex runs with -s read-only, Cursor agent with --mode ask, and agy runs headless, which denies tools automatically.
  • Web search (search only): Codex gets -c web_search="live", and Cursor agent gets --auto-review (still in ask mode) so that a search runs without asking. agy can already search in headless mode.
  • Secret check: Deckhand refuses a brief that contains a token, key or password.
  • Local records: the brief and the answer stay in a private temporary folder and are deleted after 3 days.
  • Progress in the band: while a delegate runs, a row above the usage line shows its label, CLI, model and elapsed time. When it ends, the row shows the outcome for 10 minutes, or until you press Clear finished. There is no Stop button: Deckhand only watches the Bash call that runs the delegate.
  • Records: the /delegates command, or Records on a finished row, lists the runs from the last 3 days. You can open each answer in full, copy it, or put it into the prompt box when the box is empty.
  • Requirements: the CLI must be installed and logged in. Buttons for CLIs that are not installed are hidden.

[!NOTE] Delegating sends the brief to the provider behind that CLI.

Recap

Recap explains the whole current context as plainly and briefly as possible, with analogies, in a side pane. It does not add anything to the conversation. The button reads Recap in every language.

Settings

The ⚙ button at the right end of the band, or /deckhand, opens the settings pane. It has five tabs:

TabWhat you set
GeneralDisplay language, which buttons show, the usage warning threshold
DelegatesEach delegate target: on/off, label, CLI, model ID, effort, name
Translate & searchWhether translation and web search go to a delegate, and which one (cL … gF). Both are off by default.
Sub5The workers' model, effort and maximum number of items
AdvancedCLI paths and the Codex home folder, the guards, the deploy watch, reset to defaults

Deckhand settings: the General tab and the Translate & search tab

Settings are stored per user.

Languages

English, 繁體中文, 简体中文, 日本語 and 한국어. By default the display language follows Claude Code's language setting. You can also pick a language in settings.

More tools

ToolWhat it does
/watch-deploy and the watch_deploy toolWatch a PR, a CI run or a URL in the background. The watch stops itself after N identical results in a row.
/handoff, /handoff-in, /codexHand work between Claude and Codex: write a handoff for Codex, bring Codex's latest handoff back into the prompt box, and open the Codex inbox.
Attribution guardStrips Co-Authored-By trailers and the Claude Code footer from commit and PR commands. Off by default, unless your Claude settings already turn attribution off.
Polling guardStops sleep polling loops and blocking waits such as gh run watch, and stops a status check that returns the same result N times in a row.
translateLocalized translation by the delegate you pick in settings, in the wording native speakers use rather than word for word. Off by default: turn it on in ⚙.
searchWeb research by the delegate you pick in settings: a short answer, key points and the source of each, which Claude then checks. Off by default: turn it on in ⚙.

Requirements

  • macOS or Linux. Windows is not supported; WSL has not been tested.
  • Claude Code with plugin hook modules (tested on 2.1.288).
  • Python 3.9 or newer.
  • Optional: gh for watches; the codex, Cursor agent and agy CLIs for the delegate buttons.

Privacy and safety

What leaves your machine:

  • Delegate briefs go to the provider of the CLI you chose, and only when you press a delegate button.
  • The usage readout calls Anthropic's usage endpoint with the session's own credential, through Claude Code.
  • Tools that you or Claude call on purpose reach the service they name: translate and search, once you turn them on, send their text to the delegate you picked, and a watch queries GitHub through gh or fetches the URL you gave it.

Nothing else leaves your machine.

Safety rules:

  • Delegates are read-only, enforced by each CLI's own flag or mode.
  • The secret check refuses briefs that contain tokens, keys or passwords.
  • Briefs and answers are kept in a private temporary folder and pruned after 3 days.
  • Sub5's git script never forces and never pushes.

Development

(cd plugins/deckhand && python3 -m unittest discover -s tests -p 'test_*.py')
python3 -m unittest discover -s scripts -p 'test_*.py'
python3 scripts/scan-secrets.py

scripts/scan-secrets.py checks every tracked file for keys, tokens, credentials in URLs, email addresses and home-folder paths. See CONTRIBUTING.md.

Sponsor

One-time USDT support helps maintain Deckhand's code, documentation and five-language localization. It does not buy membership, roadmap priority or support priority.

USDT · TRON (TRC20) · TGuMUi1d8MoBQcuFrGJZnu4JrbaeP3wy9a

<img src="docs/images/sponsor-usdt-trc20.png" alt="USDT (TRC20) QR: TGuMUi1d8MoBQcuFrGJZnu4JrbaeP3wy9a" width="160">

Send USDT on the TRON (TRC20) network only. Tokens sent on another network cannot be recovered.

Contributing, security and license

Contributing · Security policy · Changelog · License

Made and maintained by stephen-taipei. Deckhand is an independent plugin and is not an Anthropic product. Released under the MIT License.

Source 21 files
hooks/register.tsx 1854 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type {
5  AccountLimits,
6  CodexThread,
7  DelegateAnswer,
8  DelegateRecord,
9  DelegateRun,
10  DelegateTool,
11  EngineLimit,
12  Locale,
13  RecapState,
14  SettingsView,
15  Watch,
16} from '../types'
17import { ago, parseChecked, parseWritten, pastePrompt, stateLine, threadsArgv, unfence } from './codex'
18import { assistArgv, delegatePrompt, findTarget, SEARCH_MINUTES, targetLabel, TOOL_NAME, TRANSLATE_MINUTES } from './delegate'
19import { fingerprintBody, headerOf } from './fingerprint'
20import { blockingWait, normalizeOutput, recordStrike, statusKey, stripAttribution } from './guard'
21import type { Strike } from './guard'
22import { LANGUAGE_NAME, LOCALES, messages, resolveLocale } from './i18n'
23import type { Messages } from './i18n'
24import {
25  DEFAULT_SEARCH_SLOT,
26  DEFAULT_TRANSLATE_SLOT,
27  defaultSettings,
28  DELEGATE_EFFORTS,
29  DELEGATE_TOOLS,
30  fieldOf,
31  normalizeSettings,
32  SUB5_EFFORTS,
33  SUB5_MODELS,
34  withField,
35} from './settings'
36import type { DelegateTarget, Settings } from './settings'
37import { errorText, noteForModel } from './state'
38import {
39  clock,
40  exitCodeOf,
41  matchRecord,
42  outcomeOf,
43  parseDelegateCall,
44  parseDelegateOutput,
45  parseRecords,
46  parseTaskNotification,
47  pasteAnswer,
48  runLine,
49  whoOf,
50} from './runs'
51import type { DelegateCall } from './runs'
52import { SUB5_AGENT, sub5Prompt, workerSpec } from './sub5'
53import { buildSearchPrompt, SEARCH_SCHEMA } from './search'
54import type { SearchInput } from './search'
55import { buildPrompt, cleanOutput, findSecret, TRANSLATE_SCHEMA } from './translate'
56import type { TranslateInput } from './translate'
57import {
58  describeSources,
59  effortLevel,
60  MODEL_BUTTONS,
61  modelFamily,
62  parseAccountUsage,
63  planSwitch,
64  scopedFamily,
65  shownPercent,
66  usageSegments,
67} from './usage'
68import type { Family, Segment } from './usage'
69import { advance, describeWatch, errorResult, ghArgs, newWatch, notice, parseTarget, prResult, runResult, urlResult } from './watch'
70import type { CheckResult, PrView, RunItem, WatchSettings } from './watch'
71
72type Engine = EngineInterface
73
74const WATCH_TOOL = 'mcp__deckhand__watch_deploy'
75const TRANSLATE_TOOL = 'mcp__deckhand__translate'
76const SEARCH_TOOL = 'mcp__deckhand__search'
77const CODEX_PANE = 'deckhand-codex'
78const SETTINGS_PANE = 'deckhand-settings'
79const RECAP_PANE = 'deckhand-recap'
80const DELEGATES_PANE = 'deckhand-delegates'
81
82const text = (value: unknown) => (typeof value === 'string' ? value : '')
83
84const watchesAtom = atom({ plugin: 'deckhand', key: 'watches' } as const, [] as Watch[])
85const bandHiddenAtom = atom({ plugin: 'deckhand', key: 'isBandHidden' } as const, false)
86const codexThreadsAtom = atom({ plugin: 'deckhand', key: 'codexThreads' } as const, [] as CodexThread[])
87const codexErrorAtom = atom({ plugin: 'deckhand', key: 'codexError' } as const, null as string | null)
88const codexLoadingAtom = atom({ plugin: 'deckhand', key: 'isCodexLoading' } as const, false)
89const codexAllAtom = atom({ plugin: 'deckhand', key: 'codexAllProjects' } as const, false)
90const engineLimitsAtom = atom({ plugin: 'deckhand', key: 'engineLimits' } as const, [] as EngineLimit[])
91const accountLimitsAtom = atom({ plugin: 'deckhand', key: 'accountLimits' } as const, null as AccountLimits | null)
92const contextPercentAtom = atom({ plugin: 'deckhand', key: 'contextPercent' } as const, null as number | null)
93const contextTokensAtom = atom({ plugin: 'deckhand', key: 'contextTokens' } as const, null as number | null)
94const modelAtom = atom({ plugin: 'deckhand', key: 'model' } as const, '')
95const effortAtom = atom({ plugin: 'deckhand', key: 'effort' } as const, null as string | null)
96const settingsAtom = atom({ plugin: 'deckhand', key: 'settings' } as const, null as Settings | null)
97const localeAtom = atom({ plugin: 'deckhand', key: 'locale' } as const, 'en' as Locale)
98const recapAtom = atom({ plugin: 'deckhand', key: 'recap' } as const, { status: 'idle', text: '', at: 0 } as RecapState)
99const settingsViewAtom = atom({ plugin: 'deckhand', key: 'settingsView' } as const, { tab: 'general', editing: -1, isResetArmed: false } as SettingsView)
100const binsAtom = atom({ plugin: 'deckhand', key: 'bins' } as const, {} as Partial<Record<DelegateTool, string | null>>)
101const knownScopedAtom = atom({ plugin: 'deckhand', key: 'knownScoped' } as const, [] as string[])
102const delegateRunsAtom = atom({ plugin: 'deckhand', key: 'delegateRuns' } as const, [] as DelegateRun[])
103const delegateTickAtom = atom({ plugin: 'deckhand', key: 'delegateTick' } as const, 0)
104const recordsAtom = atom({ plugin: 'deckhand', key: 'delegateRecords' } as const, [] as DelegateRecord[])
105const recordsErrorAtom = atom({ plugin: 'deckhand', key: 'delegateRecordsError' } as const, null as string | null)
106const recordsLoadingAtom = atom({ plugin: 'deckhand', key: 'isDelegateRecordsLoading' } as const, false)
107const answerAtom = atom({ plugin: 'deckhand', key: 'delegateAnswer' } as const, null as DelegateAnswer | null)
108
109/** Timers of running watches; module state, re-armed from `$.state` after a reload. */
110const timers = new Map<string, Timer>()
111
112/** Absolute paths of CLIs: the desktop app's PATH may lack ~/.local/bin or Homebrew. */
113const bins = new Map<string, string>()
114
115async function resolveBin($: Engine, name: string) {
116  const known = bins.get(name)
117  if (known) return known
118  let found = name
119  try {
120    const home = (await $.env.get('HOME')) ?? ''
121    for (const dir of [`${home}/.local/bin`, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']) {
122      if (await $.fs.exists(`${dir}/${name}`)) {
123        found = `${dir}/${name}`
124        break
125      }
126    }
127  } catch {
128    // No fs or env here (tests): fall back to PATH lookup.
129  }
130  bins.set(name, found)
131  return found
132}
133
134// ── Settings and language ───────────────────────────────────────────────────
135
136const asRecord = (v: unknown): Record<string, unknown> => (v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : {})
137
138/**
139 * The person's settings from `$.store`, read against defaults that depend on their Claude settings
140 * (the attribution guard), and the language they resolve to. Called at session start and on demand.
141 */
142async function loadSettings($: Engine): Promise<Settings> {
143  const claude = asRecord(await $.settings.read().catch(() => ({})))
144  const base = defaultSettings({ attributionOff: asRecord(claude.attribution).commit === '' })
145  const stored = await $.store.get('settings').catch(() => undefined)
146  const s = normalizeSettings(stored, base, LOCALES)
147  await update($, settingsAtom, () => s)
148  await update($, localeAtom, () => resolveLocale(s.language, claude.language))
149  const known = await $.store.get('knownScoped').catch(() => undefined)
150  if (Array.isArray(known)) await update($, knownScopedAtom, () => known.filter((k): k is string => typeof k === 'string'))
151  return s
152}
153
154async function cfg($: Engine): Promise<Settings> {
155  return (await read($, settingsAtom)) ?? loadSettings($)
156}
157
158/** For drawing: reads only (a render hook may not write state); defaults until the session has loaded them. */
159async function cfgForDrawing($: Engine): Promise<Settings> {
160  return (await read($, settingsAtom)) ?? defaultSettings({ attributionOff: false })
161}
162
163/** The catalog for a handler: loads the settings first when the session has not, so the language is right. */
164async function msgs($: Engine): Promise<Messages> {
165  if (!(await read($, settingsAtom))) await loadSettings($)
166  return messages(await read($, localeAtom))
167}
168
169/** The catalog for drawing: reads only. */
170async function msgsForDrawing($: Engine): Promise<Messages> {
171  return messages(await read($, localeAtom))
172}
173
174const watchSettingsOf = (s: Settings): WatchSettings => ({
175  baseMs: s.watch.pollSeconds * 1000,
176  // A watch that stops after one look watches nothing: at least two identical results.
177  limit: Math.max(2, s.guards.repeatLimit),
178  stallMs: s.watch.stallMinutes * 60_000,
179})
180
181/** Saves settings, then redoes what depends on them: the language, the commands, the worker, the CLIs. */
182async function commitSettings($: Engine, next: Settings) {
183  const before = await cfg($)
184  await update($, settingsAtom, () => next)
185  await $.store.set('settings', next).catch(error => $.ui.log(`deckhand: settings not saved (${errorText(error)})`))
186  const claude = asRecord(await $.settings.read().catch(() => ({})))
187  await update($, localeAtom, () => resolveLocale(next.language, claude.language))
188  const m = await msgs($)
189  if (before.language !== next.language) await registerCommands($, m, next).catch(error => $.ui.log(`deckhand: commands not registered (${errorText(error)})`))
190  if (before.language !== next.language || JSON.stringify(before.sub5) !== JSON.stringify(next.sub5) || before.guards.attribution !== next.guards.attribution) {
191    await registerWorker($, m, next)
192  }
193  if (JSON.stringify(before.paths) !== JSON.stringify(next.paths) || JSON.stringify(before.delegates) !== JSON.stringify(next.delegates)) {
194    void checkBins($).catch(() => undefined)
195  }
196  for (const kind of ASSISTS) {
197    if (next[kind].enabled && !before[kind].enabled) {
198      await registerAssist($, next, kind).catch(error => $.ui.log(`deckhand: ${kind} tool not registered (${errorText(error)})`))
199    }
200  }
201}
202
203// ── Translate and search: work handed to a delegate, off until the person turns it on ──
204
205const ASSISTS = ['translate', 'search'] as const
206type Assist = (typeof ASSISTS)[number]
207
208/** The delegate a translation or a web search goes to: the chosen slot. */
209const assistTarget = (s: Settings, kind: Assist) =>
210  s.delegates[s[kind].slot] ?? s.delegates[kind === 'translate' ? DEFAULT_TRANSLATE_SLOT : DEFAULT_SEARCH_SLOT]!
211
212const ASSIST_TOOLS: Record<Assist, { description: string; inputSchema: Record<string, unknown> }> = {
213  translate: {
214    description:
215      'Translate or localize text (i18n JSON/TS strings, Markdown docs, UI and marketing copy) with the delegate model the user chose in the Deckhand settings (Codex, Cursor agent or agy, read-only). ' +
216      'The prompt enforces a localization standard: the idiomatic wording native speakers use in software and web products, never literal dictionary senses ' +
217      '(e.g. "fresh" → 全新/最新, not 新鮮), Taiwan vocabulary for zh-TW, and placeholders, code, URLs and markup kept intact. ' +
218      'Review the output before using it. Never pass secrets, .env content, tokens or customer data.',
219    inputSchema: TRANSLATE_SCHEMA as unknown as Record<string, unknown>,
220  },
221  search: {
222    description:
223      'Research a question on the web with the delegate model the user chose in the Deckhand settings (Codex, Cursor agent or agy; read-only, web search on). ' +
224      'It answers with a short conclusion, key points and the source URL of each. Use it instead of running many web searches yourself, ' +
225      'then check the sources your answer depends on before relying on them. Never pass secrets, .env content, tokens or customer data.',
226    inputSchema: SEARCH_SCHEMA as unknown as Record<string, unknown>,
227  },
228}
229
230/**
231 * A translate or search tool exists only once the person turns it on: with it off, the main model
232 * never sees it. There is no unregistering, so turning it off mid-session leaves it refusing calls.
233 */
234async function registerAssist($: Engine, s: Settings, kind: Assist) {
235  if (!s[kind].enabled) return
236  await $.tool.register({ name: kind, ...ASSIST_TOOLS[kind] })
237}
238
239/**
240 * Runs one translation or web search through delegate.py (the chosen CLI, read-only, secrets checked
241 * again; `--raw`: the prompt is whole and stdout is the answer alone) and answers the tool call.
242 */
243async function runAssist($: Engine, kind: Assist, prompt: string, clean: (stdout: string) => string) {
244  const m = await msgs($)
245  const s = await cfg($)
246  const words = m[kind]
247  const target = assistTarget(s, kind)
248  const who = `${target.key} · ${targetLabel(target, m)}`
249  const explicit: Record<DelegateTool, string> = { codex: s.paths.codexBin, agent: s.paths.agentBin, agy: s.paths.agyBin }
250  const minutes = kind === 'search' ? SEARCH_MINUTES : TRANSLATE_MINUTES
251  try {
252    const ran = await $.process.run(
253      assistArgv({
254        python: await resolveBin($, 'python3'),
255        tool: `${$.plugin.root}/bin/delegate.py`,
256        target,
257        locale: await read($, localeAtom),
258        bin: explicit[target.tool] || undefined,
259        codexHome: s.paths.codexHome || undefined,
260        minutes,
261        web: kind === 'search',
262      }),
263      { stdin: prompt, timeoutMs: minutes * 60_000 + 30_000 },
264    )
265    const out = clean(ran.stdout)
266    if (ran.exitCode !== 0 || !out) {
267      const why = (ran.stderr || ran.stdout).trim().split('\n').slice(-3).join(' ') || `exit ${ran.exitCode}`
268      return { deny: words.failed(who, why) }
269    }
270    return { result: `${out}\n\n---\n${words.footer(who)}` }
271  } catch (error) {
272    return { deny: words.cannotRun(who, errorText(error)) }
273  }
274}
275
276/** One edit from the ⚙ pane: kept when it survives the checks, else the old value stays and the person is told. */
277async function saveField($: Engine, field: string, value: unknown, label: string) {
278  const m = await msgs($)
279  const s = await cfg($)
280  const next = normalizeSettings(withField(s, field, value), s, LOCALES)
281  const before = fieldOf(s, field)
282  const after = fieldOf(next, field)
283  const isRefused = String(after) === String(before) && String(value).trim().toLowerCase() !== String(before).toLowerCase()
284  if (isRefused) {
285    $.ui.toast(m.settings.invalid(label))
286    return
287  }
288  await commitSettings($, next)
289  // Read the catalog again: the edit may have been the language itself.
290  $.ui.toast((await msgs($)).settings.saved, { timeoutMs: 2_000 })
291}
292
293async function registerCommands($: Engine, m: Messages, s: Settings) {
294  const rule = m.watch.stopRule(watchSettingsOf(s).limit, s.watch.stallMinutes)
295  const keys = s.delegates.filter(t => t.enabled).map(t => t.key).join('|')
296  await $.command.register({ name: 'watch-deploy', description: m.watch.commandDescription(rule), argumentHint: m.watch.commandHint })
297  await $.command.register({ name: 'handoff', description: m.codex.handoffDescription, argumentHint: m.codex.handoffHint })
298  await $.command.register({ name: 'codex', description: m.codex.codexDescription, argumentHint: m.codex.codexHint })
299  await $.command.register({ name: 'codex-latest', description: m.codex.latestDescription })
300  await $.command.register({ name: 'handoff-in', description: m.codex.handoffInDescription })
301  await $.command.register({ name: 'sub5', description: m.sub5.commandDescription(s.sub5.max), argumentHint: m.sub5.commandHint })
302  await $.command.register({ name: 'delegate', description: m.delegate.commandDescription(keys.replace(/\|/g, '/')), argumentHint: m.delegate.commandHint(keys) })
303  await $.command.register({ name: 'delegates', description: m.delegate.recordsDescription })
304  await $.command.register({ name: 'usage-raw', description: m.usage.rawCommand })
305  await $.command.register({ name: 'deckhand', description: m.settings.commandDescription })
306}
307
308async function registerWorker($: Engine, m: Messages, s: Settings) {
309  await $.agent
310    .register(workerSpec({ model: s.sub5.model, effort: s.sub5.effort, languageName: m.languageName, attribution: s.guards.attribution }))
311    .catch(error => $.ui.log(`deckhand: ${SUB5_AGENT} not registered (${errorText(error)})`))
312}
313
314/** Which delegate CLIs are installed, as bin/delegate.py finds them: a button whose CLI is missing is hidden. */
315async function checkBins($: Engine) {
316  const s = await cfg($)
317  const explicit: Record<DelegateTool, string> = { codex: s.paths.codexBin, agent: s.paths.agentBin, agy: s.paths.agyBin }
318  const found: Partial<Record<DelegateTool, string | null>> = {}
319  for (const tool of DELEGATE_TOOLS) {
320    if (!s.delegates.some(t => t.enabled && t.tool === tool)) continue
321    try {
322      const ran = await $.process.run(
323        [await resolveBin($, 'python3'), `${$.plugin.root}/bin/delegate.py`, 'check', '--tool', tool, '--format', 'json', ...(explicit[tool] ? ['--bin', explicit[tool]] : [])],
324        { timeoutMs: 15_000 },
325      )
326      const o = asRecord((() => { try { return JSON.parse(ran.stdout) as unknown } catch { return null } })())
327      const path = typeof o.path === 'string' && o.path ? o.path : typeof o.bin === 'string' && o.bin ? o.bin : null
328      const isFound = typeof o.found === 'boolean' ? o.found : path !== null ? true : ran.exitCode === 0
329      // Unknown (python missing, an unexpected answer) shows the button: delegate.py reports exit 3 itself.
330      if (ran.exitCode !== 0 && typeof o.found !== 'boolean' && path === null && ran.exitCode !== 3) continue
331      found[tool] = isFound ? (path ?? tool) : null
332    } catch {
333      // Leave it unknown.
334    }
335  }
336  await update($, binsAtom, () => found)
337}
338
339// ── Deploy watch: the engine side ───────────────────────────────────────────
340
341async function checkWatch($: Engine, w: Watch, m: Messages): Promise<CheckResult> {
342  try {
343    if (w.kind === 'url') {
344      const res = await $.http.fetch(w.target, { headers: { 'cache-control': 'no-cache' } })
345      // The content decides, not etag or last-modified: nodes of one CDN disagree on those.
346      return urlResult(w, res.status, fingerprintBody(headerOf(res.headers, 'content-type'), res.text), res.text, m)
347    }
348    const ran = await $.process.run([await resolveBin($, 'gh'), ...ghArgs(w), ...(w.repo ? ['--repo', w.repo] : [])], {
349      cwd: w.cwd,
350      timeoutMs: 60_000,
351    })
352    if (ran.exitCode !== 0) {
353      return errorResult(((ran.stderr || ran.stdout).trim().split('\n')[0] ?? '').slice(0, 160) || `gh exited ${ran.exitCode}`, m)
354    }
355    const json = JSON.parse(ran.stdout) as unknown
356    if (w.kind === 'pr') return prResult(json as PrView, m)
357    return runResult(Array.isArray(json) ? (json as RunItem[]) : [json as RunItem], m)
358  } catch (error) {
359    return errorResult(errorText(error).slice(0, 160), m)
360  }
361}
362
363function schedule($: Engine, w: Watch, s: WatchSettings) {
364  timers.get(w.id)?.cancel()
365  timers.delete(w.id)
366  if (w.status !== 'watching') return
367  timers.set(
368    w.id,
369    $.clock.after(Math.max(1000, w.intervalMs), () => void runCheck($, w.id).catch(() => undefined)),
370  )
371}
372
373async function runCheck($: Engine, id: string): Promise<Watch | undefined> {
374  const current = (await read($, watchesAtom)).find(w => w.id === id)
375  if (!current || current.status !== 'watching') return current
376  const m = await msgs($)
377  const s = watchSettingsOf(await cfg($))
378  const result = await checkWatch($, current, m)
379  const next = advance(current, result, await $.clock.now(), s, m)
380  await update($, watchesAtom, list => list.map(w => (w.id === id ? next : w)))
381  schedule($, next, s)
382  if (next.status !== 'watching') {
383    const { toast, note } = notice(next, m)
384    $.ui.toast(toast, { timeoutMs: 15_000 })
385    await $.session.append(noteForModel(note)).catch(() => undefined)
386    if (next.status === 'done' && result.followUp) {
387      await startWatch($, result.followUp, { startedBy: current.startedBy, repo: current.repo })
388    }
389  }
390  return next
391}
392
393async function startWatch(
394  $: Engine,
395  raw: string,
396  options: { expect?: string; startedBy: 'user' | 'model'; repo?: string },
397): Promise<Watch | string> {
398  const m = await msgs($)
399  const parsed = parseTarget(raw)
400  if (!parsed) return m.watch.badTarget(raw)
401  const t = !parsed.repo && options.repo ? { ...parsed, repo: options.repo } : parsed
402  const existing = (await read($, watchesAtom)).find(
403    w => w.status === 'watching' && w.kind === t.kind && w.target === t.target && w.repo === t.repo,
404  )
405  if (existing) return existing
406  const now = await $.clock.now()
407  const watch = newWatch(t, `${t.kind}-${now.toString(36)}`, await $.session.cwd(), now, watchSettingsOf(await cfg($)), {
408    expect: options.expect || undefined,
409    startedBy: options.startedBy,
410  }, m)
411  await update($, bandHiddenAtom, () => false)
412  await update($, watchesAtom, list => [...list.slice(-7), watch])
413  return (await runCheck($, watch.id)) ?? watch
414}
415
416async function stopWatches($: Engine, which: string) {
417  const m = await msgs($)
418  const hits = (await read($, watchesAtom)).filter(
419    w => w.status === 'watching' && (which === 'all' || w.id === which || w.label === which || w.target === which),
420  )
421  for (const w of hits) {
422    timers.get(w.id)?.cancel()
423    timers.delete(w.id)
424  }
425  await update($, watchesAtom, all =>
426    all.map(w => (hits.some(h => h.id === w.id) ? { ...w, status: 'stopped' as const, reason: m.watch.manual } : w)),
427  )
428  return hits.length
429}
430
431async function clearFinished($: Engine) {
432  await update($, watchesAtom, list => list.filter(w => w.status === 'watching'))
433}
434
435async function resumeWatches($: Engine) {
436  const now = await $.clock.now()
437  const s = watchSettingsOf(await cfg($))
438  for (const w of await read($, watchesAtom)) {
439    if (w.status === 'watching') schedule($, { ...w, intervalMs: Math.max(1000, w.nextAt - now) }, s)
440  }
441}
442
443// ── Codex inbox and handoff ─────────────────────────────────────────────────
444
445async function loadCodexThreads($: Engine): Promise<CodexThread[]> {
446  await update($, codexLoadingAtom, () => true)
447  try {
448    const s = await cfg($)
449    const [, ...args] = threadsArgv($.plugin.root, await $.session.root(), await read($, codexAllAtom), s.paths.codexHome)
450    const ran = await $.process.run([await resolveBin($, 'python3'), ...args], { timeoutMs: 30_000 })
451    if (ran.exitCode !== 0) throw new Error(ran.stderr.trim().split('\n').pop() || `exit ${ran.exitCode}`)
452    const threads = JSON.parse(ran.stdout) as CodexThread[]
453    await update($, codexThreadsAtom, () => threads)
454    await update($, codexErrorAtom, () => null)
455    return threads
456  } catch (error) {
457    await update($, codexErrorAtom, () => errorText(error))
458    return []
459  } finally {
460    await update($, codexLoadingAtom, () => false)
461  }
462}
463
464/** Runs bin/handoff-state.py (the tool Codex runs too) in the session's working tree. */
465async function handoffTool($: Engine, args: string[], stdin?: string) {
466  const locale = await read($, localeAtom)
467  return $.process.run(
468    [await resolveBin($, 'python3'), `${$.plugin.root}/bin/handoff-state.py`, ...args, '--cwd', await $.session.cwd(), '--lang', locale],
469    { stdin, timeoutMs: 60_000 },
470  )
471}
472
473const lastLine = (output: string) => output.trim().split('\n').pop() ?? ''
474
475/**
476 * Keeps ~/.agent-handoff/bin/handoff-state.py equal to the plugin's copy: Codex's AGENTS.md names that
477 * path, so it must not go stale when the plugin is updated. The folder is made private (700) when new.
478 * Its sibling i18n.py goes along, since the tool imports it.
479 */
480async function syncSharedTool($: Engine) {
481  try {
482    const home = (await $.env.get('HOME')) ?? ''
483    await $.process.run(['mkdir', '-p', '-m', '700', `${home}/.agent-handoff`])
484    await $.process.run(['mkdir', '-p', `${home}/.agent-handoff/bin`])
485    for (const name of ['handoff-state.py', 'i18n.py']) {
486      const sourcePath = `${$.plugin.root}/bin/${name}`
487      if (!(await $.fs.exists(sourcePath))) continue
488      const source = await $.fs.read(sourcePath)
489      const target = `${home}/.agent-handoff/bin/${name}`
490      if ((await $.fs.exists(target)) && (await $.fs.read(target)) === source) continue
491      await $.fs.write(target, source)
492    }
493  } catch (error) {
494    $.ui.log((await msgs($)).codex.syncFailed(errorText(error)))
495  }
496}
497
498// ── Usage readout ───────────────────────────────────────────────────────────
499
500const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
501/** The account's usage is asked for at most this often; after a failure, not before the backoff. */
502const ACCOUNT_MIN_MS = 5 * 60_000
503const ACCOUNT_BACKOFF_MS = 15 * 60_000
504let lastAccountTry = 0
505let accountBackoffUntil = 0
506let accountWarned = false
507
508type Measured = {
509  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
510  context: { percent?: number; tokens?: number }
511}
512
513async function storeUsage($: Engine, m: Measured) {
514  await update($, engineLimitsAtom, () => m.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt })))
515  await update($, contextPercentAtom, () => m.context.percent ?? null)
516  await update($, contextTokensAtom, () => m.context.tokens ?? null)
517}
518
519async function refreshModel($: Engine) {
520  const model = await $.session.model().catch(() => '')
521  if (model) await update($, modelAtom, () => model)
522}
523
524/**
525 * The weekly windows are nowhere in the engine's figures, so the account's own usage is asked for,
526 * through the engine: it holds the credential and sets the header, the plugin never sees the secret.
527 * Rare on purpose; a failure keeps the last good reading and backs off.
528 */
529async function refreshAccountUsage($: Engine, force = false) {
530  const now = await $.clock.now()
531  if (!force && (now - lastAccountTry < ACCOUNT_MIN_MS || now < accountBackoffUntil)) return
532  lastAccountTry = now
533  const fail = async (status: string) => {
534    accountBackoffUntil = now + ACCOUNT_BACKOFF_MS
535    const current = await read($, accountLimitsAtom)
536    if (current && current.status === 'ok') return
537    await update($, accountLimitsAtom, () => ({ windows: [], fetchedAt: now, status }))
538    // 7d has no other source: say once per session why it reads – (no credential is no news: an API key has no plan limits).
539    if (status !== 'no-credential' && !accountWarned) {
540      accountWarned = true
541      $.ui.toast((await msgs($)).usage.unreadable(status), { timeoutMs: 12_000 })
542    }
543  }
544  try {
545    const auth = await $.session.authorize()
546    if (!auth || auth.kind !== 'bearer') return await fail('no-credential')
547    const res = await $.http.fetch(USAGE_URL, {
548      auth: auth.handle,
549      headers: { accept: 'application/json', 'anthropic-beta': 'oauth-2025-04-20' },
550    })
551    if (!res.ok) return await fail(`http-${res.status}`)
552    const parsed = parseAccountUsage(JSON.parse(res.text), now, 'ok', res.text.slice(0, 1500))
553    if (!parsed) return await fail('no-windows')
554    accountBackoffUntil = 0
555    await update($, accountLimitsAtom, () => parsed)
556    // Remember which model windows this account has, so they read – (not vanish) while unreadable.
557    const families = [...new Set(parsed.windows.map(scopedFamily).filter((f): f is string => f !== null))]
558    const known = await read($, knownScopedAtom)
559    if (families.some(f => !known.includes(f))) {
560      const merged = [...new Set([...known, ...families])]
561      await update($, knownScopedAtom, () => merged)
562      await $.store.set('knownScoped', merged).catch(() => undefined)
563    }
564  } catch (error) {
565    await fail(`error: ${errorText(error).slice(0, 80)}`)
566  }
567}
568
569async function loadUsage($: Engine) {
570  await storeUsage($, await $.session.usage())
571}
572
573async function currentSegments($: Engine, warnAt: number, withContext: boolean): Promise<Segment[]> {
574  return usageSegments({
575    engine: await read($, engineLimitsAtom),
576    account: await read($, accountLimitsAtom),
577    contextPercent: withContext ? await read($, contextPercentAtom) : null,
578    now: await $.clock.now(),
579    warnAt,
580    knownScoped: await read($, knownScopedAtom),
581  })
582}
583
584const limitName = (s: Segment, m: Messages) =>
585  s.id === 'fiveHour' ? m.usage.fiveHour : s.id === 'weekly' ? m.usage.weekly : m.usage.scoped(s.name ?? s.id)
586
587/** One toast per limit and level, for the highest level crossed; it speaks again after the window resets. */
588async function warnWhenHigh($: Engine, warned: Set<string>) {
589  const warnAt = (await cfg($)).usage.warnPercent
590  const m = await msgs($)
591  for (const s of await currentSegments($, warnAt, false)) {
592    if (s.percent === null) continue
593    const crossed = [95, warnAt].find(level => s.percent! >= level)
594    if (crossed === undefined) continue
595    const key = `${s.id}@${crossed}@${s.resetsAt ?? ''}`
596    if (warned.has(key)) continue
597    warned.add(key)
598    if (crossed === 95) warned.add(`${s.id}@${warnAt}@${s.resetsAt ?? ''}`)
599    const reset = s.resetsAt ? m.usage.resets(s.resetsAt.slice(11, 16)) : ''
600    $.ui.toast(m.usage.warn(limitName(s, m), shownPercent(s.percent), reset), { timeoutMs: 20_000 })
601  }
602}
603
604// ── Model buttons ───────────────────────────────────────────────────────────
605
606/** A /model the desktop app is about to send for the person: the effort to put back once it ran. */
607let pendingSwitch: { alias: string; level: string | null; at: number } | null = null
608
609/**
610 * A click on O / F / S / H.
611 *
612 * The desktop app owns the session's model: it applies its own menu's pick to every turn, and its menu
613 * follows only a /model the person sends. A switch made behind its back is undone on the next turn,
614 * and no plugin call reaches its menu. So there the button writes `/model <family>` into the prompt
615 * box and the person presses Enter: the app's own path, menu included.
616 *
617 * Elsewhere (the terminal) the engine owns the model: `/model <family>` runs at once, then the effort
618 * the last turn ran at is put back, because switching models otherwise loads the new model's own level.
619 */
620async function pressModel($: Engine, family: Family, isWorking: boolean, surface: string) {
621  const m = await msgs($)
622  const current = await $.session.model().catch(() => '')
623  const plan = planSwitch(current, family, await read($, contextTokensAtom), m)
624  if (!plan.ok) {
625    $.ui.toast(plan.reason)
626    return
627  }
628  const name = MODEL_BUTTONS.find(b => b.family === family)!.name
629  if (surface === 'desktop') {
630    const command = `/model ${plan.alias}`
631    const box = await $.prompt.read().catch(() => ({ text: '', cursor: 0 }))
632    if (box.text.trim() && !/^\/model\b/.test(box.text.trim())) {
633      $.ui.toast(m.model.boxBusy)
634      return
635    }
636    const filled = await $.prompt.fill({ text: command, mode: 'replace' }).catch(() => ({ isFilled: false }))
637    if (filled.isFilled) pendingSwitch = { alias: plan.alias, level: plan.keepEffort ? await read($, effortAtom) : null, at: await $.clock.now() }
638    $.ui.toast(filled.isFilled ? m.model.fillReady(name, command) : m.model.fillFailed(command), { timeoutMs: 12_000 })
639    return
640  }
641  if (isWorking) {
642    $.ui.toast(m.model.busy)
643    return
644  }
645  const level = plan.keepEffort ? await read($, effortAtom) : null
646  let after = current
647  let said = ''
648  for (const alias of plan.alias === family ? [family] : [plan.alias, family]) {
649    try {
650      said = (await $.command.run({ command: 'model', args: alias })).text ?? said
651    } catch (error) {
652      said = errorText(error)
653    }
654    after = await $.session.model()
655    if (modelFamily(after) === family) break
656  }
657  await update($, modelAtom, () => after)
658  if (modelFamily(after) !== family) {
659    $.ui.toast(m.model.failed(said))
660    return
661  }
662  $.ui.toast(m.model.switched(after, level ? await restoreEffort($, level, m) : ''))
663}
664
665/** Puts the effort back; says so when this version has no /effort, instead of claiming it was kept. */
666async function restoreEffort($: Engine, level: string, m: Messages) {
667  try {
668    const commands = await $.command.list()
669    if (!commands.some(c => c.name === 'effort')) throw new Error(m.model.noEffortCommand)
670    await $.command.run({ command: 'effort', args: level })
671    return m.model.effortKept(level)
672  } catch (error) {
673    return m.model.effortNotKept(level, errorText(error))
674  }
675}
676
677// ── Sub5 ────────────────────────────────────────────────────────────────────
678
679let lastSub5At = 0
680
681/** The Sub5 button and `/sub5`: hands the main agent the flow as the user's own instruction. */
682async function startSub5($: Engine, note: string, isWorking: boolean) {
683  const m = await msgs($)
684  const s = await cfg($)
685  const now = await $.clock.now()
686  if (now - lastSub5At < 8_000) {
687    $.ui.toast(m.sub5.guard)
688    return false
689  }
690  lastSub5At = now
691  $.ui.toast(isWorking ? m.sub5.queued : m.sub5.sent, { timeoutMs: 8_000 })
692  try {
693    const brief = sub5Prompt(
694      { ...s.sub5, tool: `${$.plugin.root}/bin/sub5.py`, note, locale: await read($, localeAtom), attribution: s.guards.attribution },
695      m,
696    )
697    await $.prompt.submit({ text: brief, asUser: true })
698  } catch (error) {
699    lastSub5At = 0
700    $.ui.toast(m.sub5.failed(errorText(error)))
701    return false
702  }
703  return true
704}
705
706// ── Delegate ────────────────────────────────────────────────────────────────
707
708let lastDelegateAt = 0
709
710/**
711 * A delegate button, or `/delegate`: hands the main agent one delegation to an outside model, as the
712 * user's own instruction. The task is `task` when one is given, else (`useDraft`) the draft in the
713 * prompt box, else the conversation's current work. This never touches the main agent's model or effort.
714 */
715async function startDelegate($: Engine, target: DelegateTarget, task: string, isWorking: boolean, useDraft: boolean) {
716  const m = await msgs($)
717  const s = await cfg($)
718  const now = await $.clock.now()
719  if (now - lastDelegateAt < 8_000) {
720    $.ui.toast(m.delegate.guard)
721    return false
722  }
723  lastDelegateAt = now
724  let taskText = task.trim()
725  let draft = ''
726  if (!taskText && useDraft) {
727    // Only a surface that gives the plugin its prompt box has a draft to read; the others read ''.
728    draft = (await $.prompt.read().catch(() => ({ text: '', cursor: 0 }))).text.trim()
729    taskText = draft
730  }
731  // The draft leaves the box now, not after the turn: `submit` waits for the turn, and a draft left
732  // standing could be sent a second time.
733  if (draft) await $.prompt.fill({ text: '' }).catch(() => undefined)
734  const source = draft ? m.delegate.fromBox : taskText ? m.delegate.fromCommand : m.delegate.currentWork
735  const label = targetLabel(target, m)
736  $.ui.toast(isWorking ? m.delegate.queued(target.key, label, source) : m.delegate.sent(target.key, label, source), { timeoutMs: 10_000 })
737  const explicit: Record<DelegateTool, string> = { codex: s.paths.codexBin, agent: s.paths.agentBin, agy: s.paths.agyBin }
738  try {
739    const brief = delegatePrompt(
740      {
741        tool: `${$.plugin.root}/bin/delegate.py`,
742        target,
743        locale: await read($, localeAtom),
744        bin: explicit[target.tool] || undefined,
745        codexHome: s.paths.codexHome || undefined,
746        task: taskText,
747        attribution: s.guards.attribution,
748      },
749      m,
750    )
751    await $.prompt.submit({ text: brief, asUser: true })
752  } catch (error) {
753    lastDelegateAt = 0
754    if (draft) await $.prompt.fill({ text: draft }).catch(() => undefined)
755    $.ui.toast(m.delegate.failed(target.key, errorText(error)))
756    return false
757  }
758  return true
759}
760
761// ── Delegate runs: the band's progress rows ─────────────────────────────────
762
763/** How long a finished run's row stays in the band, unless cleared sooner. */
764const RUN_SHOWN_MS = 10 * 60_000
765/** How often a run nobody awaits (a background task, or one from before a reload) is looked up. */
766const RUN_POLL_MS = 10_000
767/** A run with no word this long past its time limit is given up as lost. */
768const RUN_LOST_MARGIN_MS = 10 * 60_000
769
770/** The one-second ticker while a run is in flight, and the timer that drops finished rows. */
771let runTicker: Timer | null = null
772let runExpiry: Timer | null = null
773let lastRunPoll = 0
774/** Runs whose Bash call a hook of this module still awaits: that call's result ends them. */
775const awaitedRuns = new Set<string>()
776
777const isFinished = (r: DelegateRun) => r.status !== 'running'
778
779/** The folders the runs so far were kept in, for `list --dir`. */
780const runDirs = (runs: readonly DelegateRun[]) => [
781  ...new Set(
782    runs.flatMap(r =>
783      r.answerPath ? [r.answerPath.split('/').slice(0, -2).join('/')] : r.folder ? [r.folder.split('/').slice(0, -1).join('/')] : [],
784    ),
785  ),
786]
787
788async function startRun($: Engine, call: DelegateCall, id: string) {
789  const run: DelegateRun = {
790    id,
791    label: call.label,
792    tool: call.tool,
793    name: call.name,
794    startedAt: await $.clock.now(),
795    timeoutMin: call.timeoutMin,
796    status: 'running',
797  }
798  awaitedRuns.add(id)
799  await update($, delegateRunsAtom, list => [...list.filter(r => r.id !== id).slice(-7), run])
800  await syncRunTimers($)
801  return id
802}
803
804/** Ends a run that is still running; one that already ended keeps its first outcome. */
805async function finishRun($: Engine, id: string, patch: Partial<DelegateRun>) {
806  const now = await $.clock.now()
807  await update($, delegateRunsAtom, list =>
808    list.map(r => (r.id === id && r.status === 'running' ? { ...r, status: 'answered' as const, ...patch, finishedAt: now } : r)),
809  )
810  await syncRunTimers($)
811}
812
813/** The Bash call's result: the outcome, or the background task it became. */
814async function settleRun($: Engine, id: string, ran: { deny?: string; result?: unknown; text?: string; isError?: true }) {
815  if (ran.deny !== undefined) {
816    await update($, delegateRunsAtom, list => list.filter(r => r.id !== id))
817    await syncRunTimers($)
818    return
819  }
820  const out = asRecord(ran.result)
821  const said = ran.text ?? [text(out.stdout), text(out.stderr)].join('\n')
822  if (!ran.isError && typeof out.backgroundTaskId === 'string' && out.backgroundTaskId) {
823    const taskId = out.backgroundTaskId
824    await update($, delegateRunsAtom, list => list.map(r => (r.id === id ? { ...r, taskId } : r)))
825    return
826  }
827  if (out.interrupted === true) return finishRun($, id, { status: 'stopped' })
828  const code = ran.isError ? exitCodeOf(said) : 0
829  await finishRun($, id, { status: code === 0 ? 'answered' : 'failed', exitCode: code, ...parseDelegateOutput(said) })
830}
831
832/** A background task's notification: ends the run that task was, with its exit code and output. */
833async function noteTaskEnded($: Engine, prompt: string) {
834  const ended = parseTaskNotification(prompt)
835  if (!ended) return
836  const run = (await read($, delegateRunsAtom)).find(
837    r =>
838      r.status === 'running' &&
839      ((ended.taskId !== undefined && r.taskId === ended.taskId) || (ended.toolUseId !== undefined && r.id === ended.toolUseId)),
840  )
841  if (!run) return
842  const said = ended.outputFile ? await $.fs.read(ended.outputFile).catch(() => '') : ''
843  const code = ended.exitCode ?? (ended.status === 'completed' ? 0 : undefined)
844  const status = ended.status === 'killed' ? 'stopped' : code === 0 ? 'answered' : 'failed'
845  await finishRun($, run.id, { status, exitCode: code, ...parseDelegateOutput(said) })
846}
847
848/** `delegate.py list`: the run folders of the last 3 days, newest first. */
849async function listRecords($: Engine, dirs: readonly string[]): Promise<DelegateRecord[]> {
850  const ran = await $.process.run(
851    [
852      await resolveBin($, 'python3'),
853      `${$.plugin.root}/bin/delegate.py`,
854      'list',
855      '--format',
856      'json',
857      '--lang',
858      await read($, localeAtom),
859      ...dirs.flatMap(d => ['--dir', d]),
860    ],
861    { timeoutMs: 30_000 },
862  )
863  if (ran.exitCode !== 0) throw new Error(lastLine(ran.stderr || ran.stdout) || `exit ${ran.exitCode}`)
864  return parseRecords(ran.stdout)
865}
866
867/** Looks up the runs nobody awaits in the run folders: a meta.json with an outcome ends one. */
868async function pollRuns($: Engine) {
869  const runs = await read($, delegateRunsAtom)
870  const open = runs.filter(r => r.status === 'running' && !awaitedRuns.has(r.id))
871  if (!open.length) return
872  const records = await listRecords($, runDirs(runs))
873  const claimed = new Set(runs.flatMap(r => (r.folder ? [r.folder] : [])))
874  for (const run of open) {
875    const rec = matchRecord(run, records, claimed)
876    if (!rec) continue
877    claimed.add(rec.path)
878    if (rec.exit === null) {
879      if (!run.folder) await update($, delegateRunsAtom, list => list.map(r => (r.id === run.id ? { ...r, folder: rec.path } : r)))
880      continue
881    }
882    await finishRun($, run.id, {
883      status: rec.exit === 0 ? 'answered' : 'failed',
884      exitCode: rec.exit,
885      folder: rec.path,
886      ...(rec.seconds !== null ? { seconds: rec.seconds } : {}),
887      ...(rec.answerPath ? { answerPath: rec.answerPath } : {}),
888    })
889  }
890}
891
892/** Every second while a run is in flight: the elapsed time moves; every few seconds the folders are looked up. */
893async function tickRuns($: Engine) {
894  const now = await $.clock.now()
895  await update($, delegateTickAtom, () => now)
896  const open = (await read($, delegateRunsAtom)).filter(r => r.status === 'running' && !awaitedRuns.has(r.id))
897  for (const r of open) {
898    if (now - r.startedAt > r.timeoutMin * 60_000 + RUN_LOST_MARGIN_MS) await finishRun($, r.id, { status: 'lost' })
899  }
900  if (open.length && now - lastRunPoll >= RUN_POLL_MS) {
901    lastRunPoll = now
902    await pollRuns($).catch(() => undefined)
903  }
904}
905
906/** Starts or stops the ticker and sets the timer that drops finished rows; after a reload, re-arms both. */
907async function syncRunTimers($: Engine) {
908  const runs = await read($, delegateRunsAtom)
909  const isRunning = runs.some(r => r.status === 'running')
910  if (isRunning && !runTicker) runTicker = $.clock.every(1000, () => void tickRuns($).catch(() => undefined))
911  if (!isRunning && runTicker) {
912    runTicker.cancel()
913    runTicker = null
914  }
915  runExpiry?.cancel()
916  runExpiry = null
917  const ends = runs.filter(isFinished).map(r => (r.finishedAt ?? 0) + RUN_SHOWN_MS)
918  if (ends.length) {
919    const wait = Math.max(1000, Math.min(...ends) - (await $.clock.now()))
920    runExpiry = $.clock.after(wait, () => void dropShownRuns($).catch(() => undefined))
921  }
922}
923
924async function dropShownRuns($: Engine) {
925  const now = await $.clock.now()
926  await update($, delegateRunsAtom, list => list.filter(r => !isFinished(r) || (r.finishedAt ?? 0) + RUN_SHOWN_MS > now))
927  await syncRunTimers($)
928}
929
930/** The band's "clear finished": finished watches and finished delegate rows alike. */
931async function clearFinishedRows($: Engine) {
932  await clearFinished($)
933  await update($, delegateRunsAtom, list => list.filter(r => r.status === 'running'))
934  await syncRunTimers($)
935}
936
937// ── Delegate records: the pane ──────────────────────────────────────────────
938
939async function loadRecords($: Engine) {
940  await update($, recordsLoadingAtom, () => true)
941  try {
942    const records = await listRecords($, runDirs(await read($, delegateRunsAtom)))
943    await update($, recordsAtom, () => records)
944    await update($, recordsErrorAtom, () => null)
945  } catch (error) {
946    await update($, recordsErrorAtom, () => errorText(error))
947  } finally {
948    await update($, recordsLoadingAtom, () => false)
949  }
950}
951
952/** Opens the records pane on the list, or on one answer when one is named. */
953async function openRecords($: Engine, show?: { path: string; label: string; who: string }) {
954  const m = await msgs($)
955  await update($, answerAtom, () => null)
956  await $.ui.open({ id: DELEGATES_PANE, title: m.delegate.recordsTitle })
957  if (show) await openAnswer($, show.path, show.label, show.who)
958  void loadRecords($).catch(() => undefined)
959}
960
961async function readAnswer($: Engine, path: string): Promise<string | null> {
962  try {
963    return await $.fs.read(path)
964  } catch (error) {
965    $.ui.toast((await msgs($)).delegate.readFailed(errorText(error)))
966    return null
967  }
968}
969
970async function openAnswer($: Engine, path: string, label: string, who: string) {
971  const answer = await readAnswer($, path)
972  if (answer !== null) await update($, answerAtom, () => ({ path, label, who, text: answer }))
973}
974
975async function copyAnswer($: Engine, path: string, surface: Parameters<Engine['ui']['copy']>[0]['surface']) {
976  const m = await msgs($)
977  const answer = await readAnswer($, path)
978  if (answer === null) return
979  const copied = await $.ui.copy({ text: answer, surface }).catch(() => ({ isCopied: false }))
980  if (copied.isCopied) $.ui.toast(m.delegate.copied, { timeoutMs: 2_000 })
981}
982
983/** Puts the answer (or, when long, where it is kept) into the prompt box; never over a draft. */
984async function answerToPrompt($: Engine, path: string, label: string, who: string) {
985  const m = await msgs($)
986  const box = await $.prompt.read().catch(() => ({ text: '', cursor: 0 }))
987  if (box.text.trim()) {
988    $.ui.toast(m.delegate.boxBusy)
989    return
990  }
991  const answer = await readAnswer($, path)
992  if (answer === null) return
993  const filled = await $.prompt
994    .fill({ text: pasteAnswer({ label, who, path, text: answer }, m), mode: 'replace' })
995    .catch(() => ({ isFilled: false }))
996  $.ui.toast(filled.isFilled ? m.delegate.filled(label) : m.delegate.fillFailed, { timeoutMs: 8_000 })
997}
998
999// ── Recap ───────────────────────────────────────────────────────────────────
1000
1001let isRecapRunning = false
1002
1003/**
1004 * The recap button: a fork of the conversation retells it in plain words, shown in a pane. The fork
1005 * reads the whole context but adds nothing to it: the conversation stays as it was.
1006 */
1007async function startRecap($: Engine) {
1008  const m = await msgs($)
1009  await $.ui.open({ id: RECAP_PANE, title: m.recap.paneTitle })
1010  if (isRecapRunning) {
1011    $.ui.toast(m.recap.guard)
1012    return
1013  }
1014  isRecapRunning = true
1015  await update($, recapAtom, () => ({ status: 'working', text: '', at: 0 }))
1016  try {
1017    const forked = await $.model.fork({ prompt: m.recap.prompt(m.languageName) })
1018    const at = await $.clock.now()
1019    if (!forked.isAnswered) {
1020      const why = forked.reason === 'nothing-to-fork' ? m.recap.nothing : m.recap.failed(forked.reason)
1021      await update($, recapAtom, () => ({ status: 'error', text: why, at }))
1022    } else {
1023      await update($, recapAtom, () => ({ status: 'done', text: unfence(forked.text), at }))
1024    }
1025  } catch (error) {
1026    await update($, recapAtom, () => ({ status: 'error', text: m.recap.failed(errorText(error)), at: 0 }))
1027  } finally {
1028    isRecapRunning = false
1029  }
1030}
1031
1032async function openSettings($: Engine) {
1033  const m = await msgs($)
1034  await $.ui.open({ id: SETTINGS_PANE, title: m.settings.title, focus: true })
1035}
1036
1037// ── Routing: what the model is told about the plugin ────────────────────────
1038
1039const who = (t: DelegateTarget) => `${t.key}: ${TOOL_NAME[t.tool]} · ${t.name}`
1040
1041const routing = (s: Settings) =>
1042  [
1043    "Deckhand (the user's Claude Code plugin):",
1044    `- To wait for a PR, CI run, merge or deploy, call ${WATCH_TOOL} once and end your turn. Never poll with sleep loops, \`gh run watch\`, \`gh pr checks --watch\` or repeated gh/curl status checks; the plugin polls locally, stops itself after repeated identical results and notifies the user.`,
1045    ...(s.translate.enabled
1046      ? [
1047          `- To translate i18n strings, docs or copy, call ${TRANSLATE_TOOL} (${who(assistTarget(s, 'translate'))}, localized-wording standard), then review terms and format before using the result. Never put secrets in it.`,
1048        ]
1049      : []),
1050    ...(s.search.enabled
1051      ? [
1052          `- For web research (docs, versions, prices, recent changes), call ${SEARCH_TOOL} (${who(assistTarget(s, 'search'))}) instead of running many searches yourself, then check the sources your answer depends on. Never put secrets in it.`,
1053        ]
1054      : []),
1055    ...(s.guards.attribution ? ['- Commit messages and PR descriptions carry no Co-Authored-By trailer and no "Generated with Claude Code" footer.'] : []),
1056  ].join('\n')
1057
1058// ── Hooks ───────────────────────────────────────────────────────────────────
1059
1060export const register: Register = on => {
1061  const strikes = new Map<string, Strike>()
1062  const warned = new Set<string>()
1063
1064  on('session.start', async ($, e, next) => {
1065    const s = await loadSettings($)
1066    const m = await msgs($)
1067    await registerCommands($, m, s)
1068    await registerWorker($, m, s)
1069    await $.tool.register({
1070      name: 'watch_deploy',
1071      description:
1072        'Watch a GitHub PR, an Actions run, all CI runs of a commit, or a deployed URL in the background, without spending tokens. ' +
1073        'Use this INSTEAD of sleep/poll loops, `gh run watch`, `gh pr checks --watch` or repeated gh/curl status checks. ' +
1074        'target: "#128", "run:123", "sha:<commit>", a GitHub PR or Actions run URL, or an https URL. ' +
1075        'expect (URL only, strongly recommended): text whose appearance means the new version is live (a commit sha, a version string). ' +
1076        'Without it the watch compares the page itself and needs two checks to confirm a change. ' +
1077        'Returns the current state at once. When the watch finishes, or stops after repeated identical results, the user gets a toast and a [deckhand] note is added to this conversation. ' +
1078        "A merged PR automatically continues as a watch of its merge commit's CI runs. After calling it, end your turn instead of polling.",
1079      inputSchema: {
1080        type: 'object',
1081        properties: {
1082          target: { type: 'string', description: 'PR / run / commit / URL to watch.' },
1083          expect: { type: 'string', description: 'For a URL: text that proves the new deploy is live.' },
1084        },
1085        required: ['target'],
1086      },
1087    })
1088    for (const kind of ASSISTS) await registerAssist($, s, kind)
1089    // The usage readout lives in the band above the prompt: no status line.
1090    $.ui.status(undefined)
1091    await syncSharedTool($)
1092    await resumeWatches($)
1093    await syncRunTimers($)
1094    await loadUsage($).catch(() => undefined)
1095    await refreshModel($)
1096    void refreshAccountUsage($, true).catch(() => undefined)
1097    void checkBins($).catch(() => undefined)
1098    return next(e)
1099  })
1100
1101  // ── Deploy / PR / CI watch ──────────────────────────────────────────────
1102
1103  on('command.run', { command: 'watch-deploy' }, async ($, e) => {
1104    const m = await msgs($)
1105    const args = e.args.trim()
1106    if (args === '' || args === 'list') {
1107      const list = await read($, watchesAtom)
1108      return { text: list.length ? list.map(w => describeWatch(w, m)).join('\n') : m.watch.none }
1109    }
1110    if (args === 'clear') {
1111      await clearFinished($)
1112      return { text: m.watch.cleared }
1113    }
1114    const stop = args.match(/^stop(?:\s+(.+))?$/)
1115    if (stop) return { text: m.watch.stoppedCount(await stopWatches($, stop[1]?.trim() || 'all')) }
1116    const expect = args.match(/\s+expect[=:]\s*(.+)$/)
1117    const target = expect ? args.slice(0, expect.index) : args
1118    const watch = await startWatch($, target, { expect: expect?.[1]?.trim(), startedBy: 'user' })
1119    return { text: typeof watch === 'string' ? watch : m.watch.started(describeWatch(watch, m)) }
1120  })
1121
1122  on('tool.call', { tool: WATCH_TOOL }, async ($, e) => {
1123    const m = await msgs($)
1124    const args = e as unknown as { target?: unknown; expect?: unknown }
1125    const target = text(args.target).trim()
1126    if (!target) return { deny: m.watch.toolNeedsTarget }
1127    const watch = await startWatch($, target, { expect: text(args.expect).trim(), startedBy: 'model' })
1128    if (typeof watch === 'string') return { deny: watch }
1129    const s = await cfg($)
1130    const tail = watch.status === 'watching' ? m.watch.toolWatching(m.watch.stopRule(watchSettingsOf(s).limit, s.watch.stallMinutes)) : m.watch.toolFinished
1131    return { result: `${describeWatch(watch, m)}\n${tail}` }
1132  }).catch(async ($, _e, next) => ({ deny: (await msgs($)).watch.toolError(errorText(next.error)) }))
1133
1134  // The band above the prompt: the running watches, then the control row: the usage readout, the model
1135  // buttons, Sub5, the delegates, the recap, a tip slot that takes the free width, and ⚙ at the right end.
1136  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1137    if (e.props.hasSurvey) return next(e)
1138    const { Box, Text, Button } = $.ui.resolve(e)
1139    const m = await msgsForDrawing($)
1140    const s = await cfgForDrawing($)
1141    const watches = await read($, watchesAtom)
1142    const hasWatches = watches.length > 0 && !(await read($, bandHiddenAtom))
1143    const hasFinished = watches.some(w => w.status !== 'watching')
1144    // Delegate runs: read the tick so the elapsed time is drawn again every second.
1145    const runs = s.show.delegates ? (await read($, delegateRunsAtom)).slice(-4) : []
1146    if (runs.some(r => r.status === 'running')) await read($, delegateTickAtom)
1147    const now = runs.length ? await $.clock.now() : 0
1148    const hasRows = hasWatches || runs.length > 0
1149    const segments = s.show.usage ? await currentSegments($, s.usage.warnPercent, true) : []
1150    const family = modelFamily(await read($, modelAtom))
1151    const found = await read($, binsAtom)
1152    const isWorking = e.props.isWorking
1153    const isDesktop = e.surface === 'desktop'
1154    // A target whose CLI is known to be missing has no button; an unchecked one keeps it.
1155    const targets = s.show.delegates ? s.delegates.filter(t => t.enabled && found[t.tool] !== null) : []
1156    const scope = (id: string) => `deckhand-tip-${id}`
1157    const tips = [
1158      ...(segments.length ? [{ id: 'usage', text: m.tips.usage }] : []),
1159      ...(s.show.models ? MODEL_BUTTONS.map(b => ({ id: `m-${b.key}`, text: isDesktop ? m.tips.modelDesktop(b.name) : m.tips.model(b.name) })) : []),
1160      ...(s.show.sub5 ? [{ id: 'sub5', text: m.tips.sub5(s.sub5.max) }] : []),
1161      ...targets.map(t => ({ id: `d-${t.key}`, text: m.tips.delegate(targetLabel(t, m)) })),
1162      ...(s.show.recap ? [{ id: 'recap', text: m.tips.recap }] : []),
1163      { id: 'gear', text: m.tips.settings },
1164      ...runs.filter(isFinished).map(r => ({ id: `dr-${r.id}`, text: m.delegate.recordsTip })),
1165    ]
1166    return (
1167      <Box flexDirection="column">
1168        {hasWatches &&
1169          watches.slice(-4).map(w => (
1170            <Box key={`w-${w.id}`} gap={1}>
1171              <Text color={w.status === 'done' ? 'green' : w.status === 'stopped' ? 'yellow' : undefined}>
1172                {w.status === 'watching' ? '⏳' : w.status === 'done' ? '✅' : '⏹'} {w.label}
1173              </Text>
1174              <Text dimColor wrap="truncate">
1175                {w.reason ?? w.summary}
1176                {w.status === 'watching' ? m.watch.checkCount(w.checks) : ''}
1177              </Text>
1178              {w.status === 'watching' && <Button key={`stop-${w.id}`} label={m.watch.stop} onPress={() => void stopWatches($, w.id)} />}
1179            </Box>
1180          ))}
1181        {runs.map(r => {
1182          const line = runLine(r, now, m)
1183          return (
1184            <Box key={`r-${r.id}`} gap={1}>
1185              <Text color={r.status === 'answered' ? 'green' : r.status === 'failed' ? 'red' : r.status === 'running' ? undefined : 'yellow'}>
1186                {line.mark} {r.label}
1187              </Text>
1188              <Text dimColor wrap="truncate">
1189                {line.text}
1190              </Text>
1191              {isFinished(r) && (
1192                <Button
1193                  key={`dr-${r.id}`}
1194                  label={m.delegate.records}
1195                  variant="secondary"
1196                  hover={{ scope: scope(`dr-${r.id}`) }}
1197                  onPress={() =>
1198                    void openRecords($, r.answerPath && r.status === 'answered' ? { path: r.answerPath, label: r.label, who: whoOf(r.tool, r.name, m) } : undefined)
1199                  }
1200                />
hooks/codex.ts 112 lines
1/**
2 * Pure helpers for the Claude ↔ Codex handoff and the Codex inbox. The handoff
3 * file itself (folder, file name, git facts, secret masking, comparison with the
4 * repo) is made by bin/handoff-state.py, which Codex runs too; this file only
5 * words the narrative request and reads the tool's answers.
6 */
7import type { CodexThread } from '../types'
8import type { Messages } from './i18n'
9
10/** Models like to wrap a whole document in a ```markdown fence; the file should hold the document. */
11export const unfence = (text: string) => {
12  const m = text.trim().match(/^```(?:markdown|md)?[ \t]*\n([\s\S]*?)\n```$/i)
13  return (m ? m[1]! : text).trim()
14}
15
16// ── answers of bin/handoff-state.py ────────────────────────────────────────
17
18export type HandoffWritten = {
19  path: string
20  latest: string
21  project: string
22  /** Secrets the tool masked in the narrative. */
23  redacted: number
24  state: Record<string, string | number>
25  nextPrompt: string
26}
27
28export type HandoffChecked = {
29  path: string
30  age: string
31  differences: number
32  report: string
33  nextPrompt: string
34}
35
36/** `check` found no usable file: `missing`, or `no-front-matter` (a file this tool did not write). */
37export type HandoffMissing = { error: string; path: string }
38
39const objectOf = (stdout: string): Record<string, unknown> | null => {
40  const lines = stdout.trim().split('\n')
41  for (const candidate of [stdout.trim(), lines[lines.length - 1] ?? '']) {
42    try {
43      const value: unknown = JSON.parse(candidate)
44      if (typeof value === 'object' && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
45    } catch {
46      // Try the last line, then give up.
47    }
48  }
49  return null
50}
51
52const text = (value: unknown) => (typeof value === 'string' ? value : '')
53
54export const parseWritten = (stdout: string): HandoffWritten | null => {
55  const o = objectOf(stdout)
56  if (!o || !text(o.path) || !text(o.next_prompt)) return null
57  const state = typeof o.state === 'object' && o.state !== null ? (o.state as Record<string, string | number>) : {}
58  return {
59    path: text(o.path),
60    latest: text(o.latest),
61    project: text(o.project),
62    redacted: typeof o.redacted === 'number' ? o.redacted : 0,
63    state,
64    nextPrompt: text(o.next_prompt),
65  }
66}
67
68export const parseChecked = (stdout: string): HandoffChecked | HandoffMissing | null => {
69  const o = objectOf(stdout)
70  if (!o) return null
71  if (text(o.error)) return { error: text(o.error), path: text(o.path) }
72  if (!text(o.path) || !text(o.next_prompt)) return null
73  return {
74    path: text(o.path),
75    age: text(o.age),
76    differences: typeof o.differences === 'number' ? o.differences : 0,
77    report: text(o.report),
78    nextPrompt: text(o.next_prompt),
79  }
80}
81
82/** One line of what the tool recorded: `project x · main @ abc1234 · uncommitted 2 · PR #7 OPEN`. */
83export const stateLine = (state: Record<string, string | number>, m: Messages) => {
84  const parts = [m.codex.project(String(state.project ?? '?'))]
85  if (state.branch) parts.push(`${state.branch}${state.head ? ` @ ${String(state.head).slice(0, 7)}` : ''}`)
86  if (state.dirty_files !== undefined) parts.push(m.codex.uncommitted(state.dirty_files, state.untracked_files ? m.codex.untracked(state.untracked_files) : ''))
87  if (state.pr_number) parts.push(`PR #${state.pr_number}${state.pr_state ? ` ${state.pr_state}` : ''}`)
88  return parts.join(' · ')
89}
90
91// ── Codex inbox ────────────────────────────────────────────────────────────
92
93export const pastePrompt = (t: CodexThread, m: Messages) => m.codex.paste(t.name, t.cwd, t.lastAssistant)
94
95export const ago = (ms: number, now: number, m: Messages) => {
96  const minutes = Math.max(0, Math.round((now - ms) / 60_000))
97  if (minutes < 60) return m.codex.minutesAgo(minutes)
98  const hours = Math.round(minutes / 60)
99  return hours < 48 ? m.codex.hoursAgo(hours) : m.codex.daysAgo(Math.round(hours / 24))
100}
101
102export const threadsArgv = (pluginRoot: string, root: string, isAll: boolean, codexHome = '') => [
103  'python3',
104  `${pluginRoot}/bin/codex-threads.py`,
105  ...(codexHome ? ['--codex-home', codexHome] : []),
106  '--days',
107  '7',
108  '--limit',
109  '12',
110  ...(isAll ? [] : ['--cwd', root]),
111]
112
hooks/delegate.ts 86 lines
1/**
2 * The delegate buttons: which outside model each one stands for (the person's settings), the exact
3 * command line that runs it, and the brief the main agent gets (one per language, in the catalogs).
4 * The exact parts (CLI flags, read-only mode, the secrets check, the time limit, the record) are
5 * bin/delegate.py's; the judgment (what to hand over, whether the answer holds) is the main agent's.
6 * Kept free of `$` so tests can read it.
7 */
8import type { Locale, Messages } from './i18n'
9import type { DelegateTarget, DelegateTool } from './settings'
10import { shq } from './shell'
11
12export const TOOL_NAME: Readonly<Record<DelegateTool, string>> = { codex: 'Codex', agent: 'Cursor agent', agy: 'agy' }
13
14/** `Codex (GPT-6 Luna, effort max)`, in the catalog's punctuation. */
15export const targetLabel = (t: DelegateTarget, m: Messages) => m.delegate.label(TOOL_NAME[t.tool], t.name, t.effort)
16
17/** The enabled target on this label, whatever its case. */
18export const findTarget = (targets: readonly DelegateTarget[], key: string) =>
19  targets.find(t => t.enabled && t.key.toLowerCase() === key.trim().toLowerCase())
20
21export type CommandArgs = {
22  /** Absolute path of bin/delegate.py. */
23  tool: string
24  target: DelegateTarget
25  locale: Locale
26  /** The CLI's path when the person set one; empty finds it. */
27  bin?: string
28  /** CODEX_HOME for a codex target, when the person set one. */
29  codexHome?: string
30}
31
32/** `run` and the target's flags, unquoted. */
33const runArgs = (o: CommandArgs) => [
34  'run',
35  '--tool',
36  o.target.tool,
37  '--model',
38  o.target.model,
39  '--effort',
40  o.target.effort,
41  '--label',
42  o.target.key,
43  '--name',
44  o.target.name,
45  '--lang',
46  o.locale,
47  ...(o.bin ? ['--bin', o.bin] : []),
48  ...(o.codexHome && o.target.tool === 'codex' ? ['--codex-home', o.codexHome] : []),
49]
50
51/** The command line the brief hands the main agent, up to the heredoc opener. */
52export const delegateCommand = (o: CommandArgs) => ['python3', o.tool, ...runArgs(o)].map(shq).join(' ')
53
54/** Minutes a translation or a web search may take before delegate.py stops the CLI. */
55export const TRANSLATE_MINUTES = 5
56export const SEARCH_MINUTES = 8
57
58/**
59 * The process of the translate and search tools: the prompt is whole (`--raw`: no standard rules in
60 * front of it) and stdout is the answer alone; `web` lets the CLI search the web (Codex needs it
61 * switched on). `python` is the interpreter's resolved path.
62 */
63export const assistArgv = (o: CommandArgs & { python: string; minutes: number; web?: boolean }) => [
64  o.python,
65  o.tool,
66  ...runArgs(o),
67  '--raw',
68  ...(o.web ? ['--web'] : []),
69  '--timeout',
70  String(o.minutes),
71]
72
73export type BriefArgs = CommandArgs & { task: string; attribution: boolean }
74
75export const delegatePrompt = (o: BriefArgs, m: Messages): string =>
76  m.delegate.brief({
77    key: o.target.key,
78    label: targetLabel(o.target, m),
79    task: o.task.trim(),
80    command: delegateCommand(o),
81    toolName: TOOL_NAME[o.target.tool],
82    readsFiles: o.target.tool !== 'agy',
83    languageName: m.languageName,
84    attribution: o.attribution,
85  })
86
hooks/fingerprint.ts 200 lines
1/**
2 * Pure fingerprints for "did it change?": the same for the same content however
3 * many times it is fetched, different when the content really changed. The URL
4 * watch and the Bash guard's repeated-check counter share them.
5 *
6 * Masking is deliberately narrow (timestamps, durations, relative times). A
7 * counter such as "3 of 5 checks passed" is real progress and must stay visible:
8 * a mask that is too wide would stop a watch while the work is still moving.
9 */
10
11/** cyrb53, a small synchronous 53-bit string hash. For change detection, not security. */
12export const hash = (text: string): string => {
13  let h1 = 0xdeadbeef
14  let h2 = 0x41c6ce57
15  for (let i = 0; i < text.length; i += 1) {
16    const ch = text.charCodeAt(i)
17    h1 = Math.imul(h1 ^ ch, 2654435761)
18    h2 = Math.imul(h2 ^ ch, 1597334677)
19  }
20  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
21  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
22  return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0')
23}
24
25// ── masking what changes on every request ──────────────────────────────────
26
27const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, 'g')
28const SPINNER = /[⠀-⣿]/g
29
30const VOLATILE: ReadonlyArray<readonly [RegExp, string]> = [
31  [/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<t>'],
32  [/\b\d{4}[-/]\d{2}[-/]\d{2}\b/g, '<d>'],
33  [/\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AP]M)?\b/gi, '<c>'],
34  [/\b(?:(?:about|less than|over|almost)\s+)?(?:a|an|\d+)\s+(?:second|minute|hour|day|week|month|year)s?\s+ago\b|\bjust now\b/gi, '<ago>'],
35  // 1m23s, 2h 5m, 10 days, 350ms. A digit or letter right after the unit means it is no duration
36  // (a commit sha such as 3d9a1f), so the whole run is left alone.
37  [/\b(?:\d+(?:\.\d+)?\s*(?:ms|secs?|seconds?|mins?|minutes?|hrs?|hours?|days?|[smhd])\s*)+(?![a-z0-9])/gi, '<dur>'],
38]
39
40export const maskVolatile = (text: string): string => {
41  let out = text.replace(ANSI, '').replace(SPINNER, '')
42  for (const [pattern, mask] of VOLATILE) out = out.replace(pattern, mask)
43  return out
44}
45
46// ── JSON: drop the fields that are about when, not about what ──────────────
47
48const VOLATILE_KEYS = new Set([
49  'timestamp', 'time', 'date', 'duration', 'elapsed', 'uptime', 'now', 'ago', 'etag', 'nonce',
50  'lastmodified', 'modified', 'requestid', 'request_id', 'traceid', 'trace_id',
51])
52const VOLATILE_SUFFIX = /(?:[a-z0-9]At|_at|Time|_time|Date|_date|Duration|_duration|Elapsed|_elapsed|Timestamp|_timestamp)$/
53
54export const isVolatileKey = (key: string) => VOLATILE_KEYS.has(key.toLowerCase()) || VOLATILE_SUFFIX.test(key)
55
56/** Keys sorted, volatile keys dropped, arrays order-insensitive: equal for equal meaning. */
57export const canonicalJson = (value: unknown): unknown => {
58  if (Array.isArray(value)) {
59    return value.map(canonicalJson).sort((a, b) => {
60      const [x, y] = [JSON.stringify(a), JSON.stringify(b)]
61      return x < y ? -1 : x > y ? 1 : 0
62    })
63  }
64  if (value !== null && typeof value === 'object') {
65    const entries = Object.entries(value as Record<string, unknown>)
66      .filter(([key]) => !isVolatileKey(key))
67      .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
68      .map(([key, v]) => [key, canonicalJson(v)] as const)
69    return Object.fromEntries(entries)
70  }
71  return value
72}
73
74/**
75 * A signature of command output that ignores noise. JSON compares by meaning; text compares
76 * by its set of distinct lines, so a retry loop that appends "Waiting…" again is no progress,
77 * while a genuinely new line is.
78 */
79export const outputSignature = (text: string): string => {
80  const trimmed = text.trim()
81  if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
82    try {
83      return `json:${hash(JSON.stringify(canonicalJson(JSON.parse(trimmed))))}`
84    } catch {
85      // Not JSON after all: compare it as text.
86    }
87  }
88  const lines = new Set<string>()
89  for (const raw of maskVolatile(text).split('\n')) {
90    const line = raw.replace(/\s+/g, ' ').trim()
91    if (line) lines.add(line)
92  }
93  return `text:${hash([...lines].sort().join('\n'))}`
94}
95
96// ── HTML: what a deploy changes, not what a request changes ────────────────
97
98const attr = (tag: string, name: string) => {
99  const m = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i').exec(tag)
100  return m ? (m[1] ?? m[2] ?? m[3] ?? '') : undefined
101}
102
103const CACHE_BUSTER = /^(?:_|t|ts|v?time|timestamp|nocache|cb|cachebust(?:er)?|rand|random|nonce)$/i
104
105const withoutBusters = (url: string) => {
106  const hashAt = url.indexOf('#')
107  const bare = hashAt === -1 ? url : url.slice(0, hashAt)
108  const q = bare.indexOf('?')
109  if (q === -1) return bare
110  const kept = bare
111    .slice(q + 1)
112    .split('&')
113    .filter(param => param && !CACHE_BUSTER.test(param.split('=')[0]!))
114  return kept.length ? `${bare.slice(0, q)}?${kept.join('&')}` : bare.slice(0, q)
115}
116
117/** Script, stylesheet and image URLs: a bundler gives them new hashed names on every build. */
118const assetsOf = (html: string) => {
119  const urls = new Set<string>()
120  for (const m of html.matchAll(/<(?:script|link|img|source|iframe|video|audio)\b[^>]*>/gi)) {
121    const url = attr(m[0], 'src') ?? attr(m[0], 'href')
122    if (url && !url.startsWith('data:')) urls.add(withoutBusters(url))
123  }
124  return [...urls].sort()
125}
126
127const BUILD_META = /^(?:build|version|commit|release|revision|git|sha|app[-_]?version|generator|deploy)/i
128
129/** `<meta name="build|version|commit|…">`: pages that state their own version. */
130const metaOf = (html: string) =>
131  [...html.matchAll(/<meta\b[^>]*>/gi)]
132    .flatMap(m => {
133      const name = attr(m[0], 'name') ?? attr(m[0], 'property')
134      const content = attr(m[0], 'content')
135      return name && content !== undefined && BUILD_META.test(name) ? [`${name.toLowerCase()}=${content}`] : []
136    })
137    .sort()
138
139/** Visible text: scripts, styles, comments and attributes (nonces, CSRF tokens) are out. */
140const textOf = (html: string) =>
141  maskVolatile(
142    html
143      .replace(/<!--[\s\S]*?-->/g, ' ')
144      .replace(/<(script|style|noscript|template)\b[\s\S]*?<\/\1>/gi, ' ')
145      .replace(/<[^>]*>/g, ' ')
146      .replace(/&nbsp;|&#160;/g, ' '),
147  )
148    .replace(/\s+/g, ' ')
149    .trim()
150
151/** Names of the parts a fingerprint is made of, by kind. */
152export const PART_NAMES: Readonly<Record<string, readonly string[]>> = {
153  html: ['assets', 'meta', 'text'],
154  json: ['body'],
155  text: ['body'],
156}
157
158/**
159 * `html:<assets>.<meta>.<text>`, `json:<body>` or `text:<body>`. Each part is its own hash,
160 * so a page that keeps changing can be told which part keeps changing.
161 */
162export const fingerprintBody = (contentType: string | undefined, body: string): string => {
163  const head = body.trimStart().slice(0, 64).toLowerCase()
164  const isHtml = (contentType ?? '').toLowerCase().includes('html') || /^<(?:!doctype html|html|head|body)/.test(head)
165  if (isHtml) {
166    return `html:${[assetsOf(body).join('\n'), metaOf(body).join('\n'), textOf(body)].map(hash).join('.')}`
167  }
168  return outputSignature(body)
169}
170
171/** A header by name, whatever case the host spelled it in. */
172export const headerOf = (headers: Readonly<Record<string, string>>, name: string): string | undefined => {
173  const wanted = name.toLowerCase()
174  for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === wanted) return value
175  return undefined
176}
177
178const partsOf = (signature: string) => {
179  const fp = signature.slice(signature.indexOf('|') + 1)
180  const colon = fp.indexOf(':')
181  return { kind: fp.slice(0, colon), hashes: fp.slice(colon + 1).split('.') }
182}
183
184/** Which parts of a series of `<status>|<fingerprint>` signatures differ between neighbours. */
185export const changingParts = (signatures: readonly string[]): string[] => {
186  const names = new Set<string>()
187  for (let i = 1; i < signatures.length; i += 1) {
188    const before = partsOf(signatures[i - 1]!)
189    const after = partsOf(signatures[i]!)
190    if (before.kind !== after.kind) {
191      names.add('type')
192      continue
193    }
194    after.hashes.forEach((h, k) => {
195      if (h !== before.hashes[k]) names.add(PART_NAMES[after.kind]?.[k] ?? 'body')
196    })
197  }
198  return [...names]
199}
200
hooks/guard.ts 114 lines
1/**
2 * Pure helpers for the Bash guards: attribution stripping, blocking waits and
3 * the 3-strike rule for repeated status checks. Kept free of `$` so tests can
4 * call them.
5 */
6import { outputSignature } from './fingerprint'
7import type { Messages } from './i18n'
8
9const ATTRIBUTION = [
10  /Co-Authored-By:[^\n"'`]*/gi,
11  /🤖\s*Generated with \[Claude Code\]\([^)\n]*\)/g,
12  /Generated with \[Claude Code\]\([^)\n]*\)/g,
13]
14
15const WRITES_MESSAGE = /\bgit\b[^\n|;&]*\bcommit\b|\bgh\s+pr\s+(create|edit)\b|\bgit\b[^\n|;&]*\btag\b\s+-[am]/
16
17/** Removes Co-Authored-By trailers and the Claude Code PR footer from a commit / PR command. */
18export const stripAttribution = (command: string): string | null => {
19  if (!WRITES_MESSAGE.test(command)) return null
20  const kept: string[] = []
21  let isChanged = false
22  for (const line of command.split('\n')) {
23    let next = line
24    for (const pattern of ATTRIBUTION) next = next.replace(pattern, '')
25    if (next === line) {
26      kept.push(line)
27      continue
28    }
29    isChanged = true
30    const isLeading = /^\s*(Co-Authored-By:|🤖|Generated with)/i.test(line)
31    if (next.trim() !== '' && !isLeading) {
32      kept.push(next)
33      continue
34    }
35    // The line opened with the trailer: drop it and the blank lines that set it
36    // apart, and hand what closed the message (a quote, a paren) to the line before.
37    while (kept.length > 0 && kept[kept.length - 1]!.trim() === '') kept.pop()
38    const rest = next.trim()
39    if (rest === '') continue
40    if (kept.length > 0) kept[kept.length - 1] += rest
41    else kept.push(rest)
42  }
43  return isChanged ? kept.join('\n') : null
44}
45
46/** Long blocking waits the watch tool replaces. */
47export const blockingWait = (command: string, m: Messages): string | null => {
48  if (/\bgh\s+run\s+watch\b/.test(command)) return 'gh run watch'
49  if (/\bgh\s+pr\s+checks\b[^\n]*--watch\b/.test(command)) return 'gh pr checks --watch'
50  // A loop that sleeps and calls a status tool, in whichever order the words come.
51  if (/\b(?:until|while)\b/.test(command) && /\bsleep\b/.test(command) && /\b(?:gh|curl|wget|ssh)\b/.test(command)) {
52    return m.guard.loop
53  }
54  const sleep = command.match(/\bsleep\s+(\d+)/)
55  if (sleep && Number(sleep[1]) >= 20 && /\b(gh|curl|wget|ssh)\b/.test(command)) return m.guard.sleepPoll(sleep[1]!)
56  return null
57}
58
59// ── repeated status checks ─────────────────────────────────────────────────
60
61/** Parts of a command line that wait or print, not check: they change between tries, the check does not. */
62const FILLER = /^(?:sleep\s+[\d.]+[smhd]?|echo\b.*|printf\b.*|date\b.*|true|:)$/
63
64const STATUS_CHECK = new RegExp(
65  [
66    String.raw`\bgh\s+pr\s+(?:view|checks|status)\b`,
67    String.raw`\bgh\s+run\s+(?:list|view)\b`,
68    String.raw`\bgh\s+api\b[^\n|]*\/(?:actions\/runs|deployments|check-runs|status|pulls\/\d+)`,
69    String.raw`\bcurl\b[^\n]*(?:https?:\/\/|localhost)`,
70    String.raw`\bgit\s+(?:fetch|ls-remote)\b`,
71    String.raw`\bssh\b[^\n]*\b(?:cat|ls|readlink|stat|tail|head|systemctl\s+status|docker\s+(?:ps|logs)|journalctl)\b`,
72  ].join('|'),
73)
74
75const WRITES =
76  /\b(?:git\s+(?:push|commit|merge|pull|rebase|reset|checkout|switch)|gh\s+pr\s+(?:merge|create|edit|close)|rsync|scp|deploy)\b/
77
78/** A request that changes something is no status check, however often it is repeated. */
79const isMutating = (command: string) =>
80  /\bgh\s+api\b/.test(command)
81    ? /(?:^|\s)(?:-X|--method|-f|-F|--field|--raw-field|--input)(?=[\s=]|$)/.test(command)
82    : /(?:^|\s)(?:-X\s*(?:POST|PUT|PATCH|DELETE)|--request[=\s]+(?:POST|PUT|PATCH|DELETE)|-d|--data\S*|-F|--form\S*|-T|--upload-file)(?=[\s=]|$)/.test(command)
83
84/**
85 * A stable key for "the same check": the status-style part of the command, without
86 * the sleeps, echoes, `2>&1` and `| tail` a model varies from try to try.
87 */
88export const statusKey = (command: string): string | null => {
89  const key = command
90    .split(/&&|\|\||;|\n/)
91    .map(part => part.trim())
92    .filter(part => part !== '' && !FILLER.test(part))
93    .join(' && ')
94    .replace(/\s*2>&1/g, '')
95    .replace(/\s*\|\s*(?:tail|head)\b[^|]*$/, '')
96    .replace(/\s+/g, ' ')
97    .trim()
98  if (!key || !STATUS_CHECK.test(key) || WRITES.test(key) || isMutating(key)) return null
99  return key.slice(0, 400)
100}
101
102/** What counts as "the same result": JSON by meaning, text by its distinct lines, noise masked. */
103export const normalizeOutput = outputSignature
104
105export type Strike = { output: string; count: number }
106
107/** Counts consecutive identical results per check; a changed result starts over at 1. */
108export const recordStrike = (strikes: Map<string, Strike>, key: string, output: string): number => {
109  const prior = strikes.get(key)
110  const count = prior && prior.output === output ? prior.count + 1 : 1
111  strikes.set(key, { output, count })
112  return count
113}
114
hooks/i18n.ts 56 lines
1/**
2 * Which language the band, the panes, the toasts and the briefs speak. `auto` follows Claude Code's own
3 * `language` setting, a free-text value (`正體中文`, `japanese`, `en`), so names and tags both count.
4 * Pure: the catalogs are data, `messages(locale)` picks one.
5 */
6import type { Locale } from '../types'
7import { en } from './i18n/en'
8import type { Messages } from './i18n/en'
9import { ja } from './i18n/ja'
10import { ko } from './i18n/ko'
11import { zhCN } from './i18n/zh-CN'
12import { zhTW } from './i18n/zh-TW'
13
14export type { Messages } from './i18n/en'
15
16export type { Locale } from '../types'
17export const LOCALES: readonly Locale[] = ['en', 'zh-TW', 'zh-CN', 'ja', 'ko']
18
19export const CATALOG: Readonly<Record<Locale, Messages>> = { en, 'zh-TW': zhTW, 'zh-CN': zhCN, ja, ko }
20
21export const messages = (locale: Locale): Messages => CATALOG[locale] ?? en
22
23/** Each language as its speakers write it: what a brief asks the model to answer in. */
24export const LANGUAGE_NAME: Readonly<Record<Locale, string>> = {
25  en: 'English',
26  'zh-TW': '臺灣繁體中文',
27  'zh-CN': '简体中文',
28  ja: '日本語',
29  ko: '한국어',
30}
31
32const TRADITIONAL = /(zh[-_]?(tw|hk|mo|hant)|traditional|正體|繁體|繁体|臺灣|台灣|台湾|香港|taiwan)/i
33const SIMPLIFIED = /(zh[-_]?(cn|sg|hans)|simplified|简体|簡體|简中|大陆|大陸|mainland)/i
34const CHINESE = /^(zh|chinese|中文|汉语|漢語|華語|华语)$/i
35
36/**
37 * A language setting or a locale tag to one of the five. `chinese` alone leans on the environment
38 * (`LANG=zh_TW.UTF-8` reads Traditional); anything unknown is English.
39 */
40export const normalizeLocale = (value: unknown, envLang = ''): Locale | null => {
41  if (typeof value !== 'string') return null
42  const v = value.trim()
43  if (!v) return null
44  if (TRADITIONAL.test(v)) return 'zh-TW'
45  if (SIMPLIFIED.test(v)) return 'zh-CN'
46  if (CHINESE.test(v)) return TRADITIONAL.test(envLang) ? 'zh-TW' : 'zh-CN'
47  if (/^(ja|jp)([-_.]|$)|japanese|日本語|日本/i.test(v)) return 'ja'
48  if (/^ko([-_.]|$)|korean|한국어|한국|韓/i.test(v)) return 'ko'
49  if (/^en([-_.]|$)|english|英文|英語|英语/i.test(v)) return 'en'
50  return null
51}
52
53/** `auto` reads Claude Code's language, then the environment; a pick in settings wins. */
54export const resolveLocale = (choice: 'auto' | Locale, claudeLanguage: unknown, envLang = ''): Locale =>
55  choice !== 'auto' ? choice : (normalizeLocale(claudeLanguage, envLang) ?? normalizeLocale(envLang) ?? 'en')
56
hooks/settings.ts 180 lines
1/**
2 * The person's settings: what the ⚙ pane edits, kept in `$.store` across sessions. Personal choices
3 * (models, efforts, CLI paths, the attribution rule) live here as data, not in the code. Pure: the
4 * shape, the defaults and the clamping, so tests can read it.
5 */
6import type { DeckhandSettings, DelegateTarget, DelegateTool, Locale } from '../types'
7
8export type { DelegateTarget, DelegateTool } from '../types'
9export type Settings = DeckhandSettings
10
11export const DELEGATE_TOOLS: readonly DelegateTool[] = ['codex', 'agent', 'agy']
12
13/** Levels bin/delegate.py accepts. For agent and agy the effort is part of the model id: display only. */
14export const DELEGATE_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const
15
16export const SUB5_MODELS = ['sonnet', 'opus', 'fable', 'haiku'] as const
17export const SUB5_EFFORTS = ['medium', 'high', 'xhigh', 'max'] as const
18export const DELEGATE_SLOTS = 5
19
20/** The delegate buttons in band order. A label's case is kept: `cL`, not `CL`. */
21export const DEFAULT_DELEGATES: readonly DelegateTarget[] = [
22  { key: 'cL', enabled: true, tool: 'codex', model: 'gpt-6-luna', effort: 'max', name: 'GPT-6 Luna' },
23  { key: 'cS', enabled: true, tool: 'codex', model: 'gpt-6.1-sol', effort: 'medium', name: 'GPT-6.1 Sol' },
24  { key: 'cA', enabled: true, tool: 'codex', model: 'gpt-6-astra', effort: 'medium', name: 'GPT-6 Astra' },
25  { key: 'cR', enabled: true, tool: 'agent', model: 'grok-4.7-high', effort: 'high', name: 'Grok 4.7' },
26  { key: 'gF', enabled: true, tool: 'agy', model: 'gemini-3.8-flash-high', effort: 'high', name: 'Gemini 3.8 Flash' },
27]
28
29/** The labels settings version 1 shipped with: a label still on one of them follows the new default. */
30const V1_KEYS = ['CL', 'CS', 'CA', 'CR', 'GF'] as const
31
32/** The delegate slot translation and web search go to by default: gF (agy · Gemini Flash). */
33export const DEFAULT_TRANSLATE_SLOT = 4
34export const DEFAULT_SEARCH_SLOT = 4
35
36/**
37 * A first run's settings. The attribution guard is on only for someone whose Claude settings already
38 * turn attribution off (`attribution.commit: ""`): stripping trailers is their rule, not everyone's.
39 * Translation and web search by a delegate are off until the person turns them on: not everyone
40 * wants text sent to another provider.
41 */
42export const defaultSettings = (o: { attributionOff: boolean }): Settings => ({
43  version: 3,
44  language: 'auto',
45  show: { usage: true, models: true, sub5: true, delegates: true, recap: true },
46  delegates: DEFAULT_DELEGATES.map(d => ({ ...d })),
47  sub5: { max: 5, model: 'sonnet', effort: 'max' },
48  paths: { codexHome: '', codexBin: '', agentBin: '', agyBin: '' },
49  guards: { attribution: o.attributionOff, polling: true, repeatLimit: 3 },
50  watch: { pollSeconds: 90, stallMinutes: 10 },
51  usage: { warnPercent: 80 },
52  translate: { enabled: false, slot: DEFAULT_TRANSLATE_SLOT },
53  search: { enabled: false, slot: DEFAULT_SEARCH_SLOT },
54})
55
56export const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/@+\-[\]=,]{0,99}$/
57export const LABEL = /^[A-Za-z0-9]{1,6}$/
58
59const obj = (v: unknown): Record<string, unknown> =>
60  v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
61const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
62const num = (v: unknown, d: number, min: number, max: number) =>
63  typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : d
64const oneOf = <T extends string>(v: unknown, list: readonly T[], d: T): T =>
65  typeof v === 'string' && (list as readonly string[]).includes(v) ? (v as T) : d
66const str = (v: unknown, d: string, max = 400) => (typeof v === 'string' ? v.trim().slice(0, max) : d)
67/** A path is a path: no line breaks, no NUL, nothing a shell quote would have to fight. */
68const path = (v: unknown, d: string) => {
69  const s = str(v, d)
70  return /[\n\r\0']/.test(s) ? d : s
71}
72
73export const normalizeTarget = (v: unknown, d: DelegateTarget): DelegateTarget => {
74  const o = obj(v)
75  const key = typeof o.key === 'string' && LABEL.test(o.key.trim()) ? o.key.trim() : d.key
76  const model = typeof o.model === 'string' && MODEL_ID.test(o.model.trim()) ? o.model.trim() : d.model
77  const name = typeof o.name === 'string' && o.name.trim() && o.name.length <= 40 && !/[\n\r']/.test(o.name) ? o.name.trim() : d.name
78  return {
79    key,
80    enabled: bool(o.enabled, d.enabled),
81    tool: oneOf(o.tool, DELEGATE_TOOLS, d.tool),
82    model,
83    effort: oneOf(o.effort, DELEGATE_EFFORTS, d.effort as (typeof DELEGATE_EFFORTS)[number]),
84    name,
85  }
86}
87
88/** Whatever the store holds, read against `base`: unknown keys dropped, bad values kept at the base's. */
89export const normalizeSettings = (raw: unknown, base: Settings, locales: readonly Locale[]): Settings => {
90  const r = obj(raw)
91  const show = obj(r.show)
92  const sub5 = obj(r.sub5)
93  const paths = obj(r.paths)
94  const guards = obj(r.guards)
95  const watch = obj(r.watch)
96  const usage = obj(r.usage)
97  const translate = obj(r.translate)
98  const search = obj(r.search)
99  // Version 1 (the builds before 1.0.0) had upper-case labels. Before version 3 translation was on without the
100  // person choosing it: from version 3 on, only their own switch turns it on.
101  const isV1 = r.version === 1
102  const isOptIn = typeof r.version === 'number' && r.version >= 3
103  const stored = Array.isArray(r.delegates) ? r.delegates : []
104  const delegates = base.delegates.map((d, i) => {
105    const t = normalizeTarget(stored[i], d)
106    return isV1 && t.key === V1_KEYS[i] ? { ...t, key: d.key } : t
107  })
108  // Two buttons on one label (in any case: `/delegate cl` finds `cL`) would press the same target:
109  // the later one gets its slot's default back.
110  const seen = new Set<string>()
111  for (let i = 0; i < delegates.length; i++) {
112    if (seen.has(delegates[i]!.key.toLowerCase())) delegates[i] = { ...delegates[i]!, key: `${DEFAULT_DELEGATES[i]?.key ?? 'D'}${i + 1}`.slice(0, 6) }
113    seen.add(delegates[i]!.key.toLowerCase())
114  }
115  return {
116    version: 3,
117    language:
118      r.language === 'auto'
119        ? 'auto'
120        : typeof r.language === 'string' && (locales as readonly string[]).includes(r.language)
121          ? (r.language as Locale)
122          : base.language,
123    show: {
124      usage: bool(show.usage, base.show.usage),
125      models: bool(show.models, base.show.models),
126      sub5: bool(show.sub5, base.show.sub5),
127      delegates: bool(show.delegates, base.show.delegates),
128      recap: bool(show.recap, base.show.recap),
129    },
130    delegates,
131    sub5: {
132      max: num(sub5.max, base.sub5.max, 1, 8),
133      model: oneOf(sub5.model, SUB5_MODELS, base.sub5.model as (typeof SUB5_MODELS)[number]),
134      effort: oneOf(sub5.effort, SUB5_EFFORTS, base.sub5.effort as (typeof SUB5_EFFORTS)[number]),
135    },
136    paths: {
137      codexHome: path(paths.codexHome, base.paths.codexHome),
138      codexBin: path(paths.codexBin, base.paths.codexBin),
139      agentBin: path(paths.agentBin, base.paths.agentBin),
140      agyBin: path(paths.agyBin, base.paths.agyBin),
141    },
142    guards: {
143      attribution: bool(guards.attribution, base.guards.attribution),
144      polling: bool(guards.polling, base.guards.polling),
145      repeatLimit: num(guards.repeatLimit, base.guards.repeatLimit, 2, 10),
146    },
147    watch: {
148      pollSeconds: num(watch.pollSeconds, base.watch.pollSeconds, 15, 600),
149      stallMinutes: num(watch.stallMinutes, base.watch.stallMinutes, 0, 120),
150    },
151    usage: { warnPercent: num(usage.warnPercent, base.usage.warnPercent, 50, 99) },
152    translate: {
153      enabled: isOptIn ? bool(translate.enabled, base.translate.enabled) : base.translate.enabled,
154      slot: num(translate.slot, base.translate.slot, 0, base.delegates.length - 1),
155    },
156    search: {
157      enabled: isOptIn ? bool(search.enabled, base.search.enabled) : base.search.enabled,
158      slot: num(search.slot, base.search.slot, 0, base.delegates.length - 1),
159    },
160  }
161}
162
163/** Reads one field by its path (`sub5.max`, `delegates.2.model`); undefined when there is none. */
164export const fieldOf = (s: Settings, field: string): unknown =>
165  field.split('.').reduce<unknown>((at, p) => (at !== null && typeof at === 'object' ? (at as Record<string, unknown>)[p] : undefined), s)
166
167/** Sets one field by its path (`sub5.max`, `delegates.2.model`), as a pane edit does. */
168export const withField = (s: Settings, field: string, value: unknown): Settings => {
169  const copy = JSON.parse(JSON.stringify(s)) as Record<string, unknown>
170  const parts = field.split('.')
171  let at: Record<string, unknown> | unknown[] = copy
172  for (const p of parts.slice(0, -1)) {
173    const next: unknown = (at as Record<string, unknown>)[p]
174    if (next === null || typeof next !== 'object') return s
175    at = next as Record<string, unknown>
176  }
177  ;(at as Record<string, unknown>)[parts[parts.length - 1]!] = value
178  return copy as unknown as Settings
179}
180
hooks/state.ts 7 lines
1/** `$.session.append`'s argument: a hidden user note the model reads on its next request. */
2export const noteForModel = (text: string) => ({
3  message: { type: 'user' as const, content: [{ type: 'text' as const, text: `[deckhand] ${text}` }] },
4})
5
6export const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
7
hooks/runs.ts 196 lines
1/**
2 * Delegate runs as the band and the records pane see them: which Bash call is a `delegate.py run`,
3 * what its output and its background task's notification say, and the run folders `delegate.py list`
4 * reports. Kept free of `$` so tests can read it. The brief (the heredoc) is never read.
5 */
6import type { DelegateRecord, DelegateRun, DelegateTool } from '../types'
7import { TOOL_NAME } from './delegate'
8import type { Messages } from './i18n'
9
10/** delegate.py's own default --timeout, in minutes. */
11export const DEFAULT_TIMEOUT_MIN = 30
12
13const TOOLS: readonly DelegateTool[] = ['codex', 'agent', 'agy']
14
15/** The shell words of one line, quotes undone, up to a heredoc, a pipe, a redirect or a separator. */
16const words = (text: string): string[] => {
17  const out: string[] = []
18  let word = ''
19  let quote = ''
20  let isWord = false
21  for (let i = 0; i < text.length; i += 1) {
22    const c = text[i]!
23    if (quote) {
24      if (c === quote) quote = ''
25      else if (c === '\\' && quote === '"' && i + 1 < text.length) word += text[++i]
26      else word += c
27      continue
28    }
29    if (c === "'" || c === '"') {
30      quote = c
31      isWord = true
32    } else if (c === '\\' && i + 1 < text.length) {
33      word += text[++i]
34      isWord = true
35    } else if (/\s/.test(c)) {
36      if (isWord) out.push(word)
37      word = ''
38      isWord = false
39    } else if (';&|<>'.includes(c)) {
40      if (isWord) out.push(word)
41      return out
42    } else {
43      word += c
44      isWord = true
45    }
46  }
47  if (isWord) out.push(word)
48  return out
49}
50
51export type DelegateCall = { label: string; tool?: DelegateTool; name?: string; timeoutMin: number }
52
53/**
54 * The flags of the first `delegate.py run` in a Bash command, or null when there is none (a dry run
55 * runs nothing, so it is none). Only the command line up to the heredoc is read, never the brief.
56 */
57export const parseDelegateCall = (command: string): DelegateCall | null => {
58  const found = command.match(/delegate\.py['"]?[ \t]+run(?=[\s;&|<]|$)/)
59  if (!found) return null
60  const rest = command.slice(found.index! + found[0].length).replace(/\\\r?\n/g, ' ').split('\n')[0]!
61  const flags = words(rest)
62  if (flags.includes('--dry-run')) return null
63  const flag = (name: string) => {
64    for (let i = 0; i < flags.length; i += 1) {
65      if (flags[i] === name) return flags[i + 1]
66      if (flags[i]!.startsWith(`${name}=`)) return flags[i]!.slice(name.length + 1)
67    }
68    return undefined
69  }
70  const tool = TOOLS.find(t => t === flag('--tool'))
71  const label = (flag('--label') ?? tool ?? 'delegate').slice(0, 12)
72  const name = flag('--name')?.trim().slice(0, 40) || flag('--model')?.slice(0, 40) || undefined
73  const timeout = Number(flag('--timeout'))
74  return { label, tool, name, timeoutMin: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_TIMEOUT_MIN }
75}
76
77/** A run folder's answer file, in any of the languages delegate.py prints. */
78const ANSWER_PATH = /(\/\S*?\/\d{8}-\d{6}-[A-Za-z0-9]{1,6}-[0-9a-f]{4}\/answer\.md)/
79
80/**
81 * What a `delegate.py run`'s output says: the seconds on its `[delegate] … · N s · outcome` line (the
82 * unit is the language's: s, 秒, 초) and the answer file's path.
83 */
84export const parseDelegateOutput = (text: string): { seconds?: number; answerPath?: string } => {
85  const header = text.split('\n').find(line => line.startsWith('[delegate] ')) ?? ''
86  const seconds = header.match(/·\s*(\d+(?:\.\d+)?)\s*(?:s|秒|초)\s*·/)
87  const path = text.match(ANSWER_PATH)
88  return {
89    ...(seconds ? { seconds: Number(seconds[1]) } : {}),
90    ...(path ? { answerPath: path[1] } : {}),
91  }
92}
93
94/** The exit code an errored Bash result reports (`Exit code 5`), when it does. */
95export const exitCodeOf = (text: string): number | undefined => {
96  const m = text.match(/^Exit code (\d+)/m)
97  return m ? Number(m[1]) : undefined
98}
99
100export type TaskEnded = { taskId?: string; toolUseId?: string; status?: string; exitCode?: number; outputFile?: string }
101
102/** A background task's notification, as the engine words it; null for any other prompt. */
103export const parseTaskNotification = (text: string): TaskEnded | null => {
104  if (!text.includes('<task-notification>')) return null
105  const tag = (name: string) => text.match(new RegExp(`<${name}>([^<]*)</${name}>`))?.[1]?.trim() || undefined
106  const code = (tag('summary') ?? '').match(/exit code (\d+)/i)
107  return {
108    taskId: tag('task-id'),
109    toolUseId: tag('tool-use-id'),
110    status: tag('status'),
111    exitCode: code ? Number(code[1]) : undefined,
112    outputFile: tag('output-file'),
113  }
114}
115
116/** `1:05`, `12:40`, `1:02:03`. */
117export const clock = (ms: number) => {
118  const s = Math.max(0, Math.floor(ms / 1000))
119  const mm = String(Math.floor(s / 60) % 60)
120  const ss = String(s % 60).padStart(2, '0')
121  return s >= 3600 ? `${Math.floor(s / 3600)}:${mm.padStart(2, '0')}:${ss}` : `${mm}:${ss}`
122}
123
124/** `Codex · GPT-6 Luna`, or what is known of it. */
125export const whoOf = (tool: DelegateTool | null | undefined, name: string | null | undefined, m: Messages) =>
126  tool ? `${TOOL_NAME[tool]} · ${name || '?'}` : name || m.delegate.notRecorded
127
128/** What a finished run's exit code means, in the catalog's words. */
129export const outcomeOf = (code: number | null | undefined, m: Messages) => (code === 0 ? m.delegate.answered : m.delegate.exitWord(code ?? null))
130
131/** The band's words for one run: the mark, and the line after the label. */
132export const runLine = (r: DelegateRun, now: number, m: Messages): { mark: string; text: string } => {
133  if (r.status === 'running') {
134    const parts = [...(r.tool ? [TOOL_NAME[r.tool]] : []), ...(r.name ? [r.name] : []), clock(now - r.startedAt)]
135    return { mark: '⏳', text: parts.join(' · ') + (r.taskId ? ` · ${m.delegate.inBackground}` : '') }
136  }
137  if (r.status === 'answered') {
138    const ms = r.seconds !== undefined ? r.seconds * 1000 : (r.finishedAt ?? now) - r.startedAt
139    return { mark: '✅', text: `${clock(ms)} · ${m.delegate.answered}` }
140  }
141  if (r.status === 'failed') return { mark: '❌', text: outcomeOf(r.exitCode, m) }
142  if (r.status === 'stopped') return { mark: '⏹', text: m.delegate.interrupted }
143  return { mark: '⚠', text: m.delegate.lost }
144}
145
146const str = (v: unknown) => (typeof v === 'string' && v ? v : null)
147const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
148
149/** `delegate.py list --format json`'s answer, checked; throws when it is no list. */
150export const parseRecords = (stdout: string): DelegateRecord[] => {
151  const raw = JSON.parse(stdout) as unknown
152  if (!Array.isArray(raw)) throw new Error('delegate.py list did not answer a list')
153  return raw.flatMap((x): DelegateRecord[] => {
154    if (!x || typeof x !== 'object') return []
155    const o = x as Record<string, unknown>
156    const id = str(o.id)
157    const path = str(o.path)
158    if (!id || !path) return []
159    return [
160      {
161        id,
162        path,
163        label: str(o.label) ?? '?',
164        started: num(o.started),
165        tool: TOOLS.find(t => t === o.tool) ?? null,
166        model: str(o.model),
167        name: str(o.name),
168        seconds: num(o.seconds),
169        exit: num(o.exit),
170        answerPath: str(o.answer_path),
171        answerBytes: num(o.answer_bytes),
172        hasStderr: o.has_stderr === true,
173        firstLine: str(o.first_line) ?? '',
174      },
175    ]
176  })
177}
178
179/**
180 * The run folder a run in flight is, among the listed ones: its own once matched, else the earliest
181 * one of its label started at or after it (a few seconds' leeway) that no other run has claimed.
182 */
183export const matchRecord = (run: DelegateRun, records: readonly DelegateRecord[], claimed: ReadonlySet<string>) => {
184  if (run.folder) return records.find(r => r.path === run.folder)
185  return records
186    .filter(r => r.label === run.label && r.started !== null && r.started * 1000 >= run.startedAt - 5_000 && !claimed.has(r.path))
187    .sort((a, b) => a.started! - b.started!)[0]
188}
189
190/** Answers longer than this go into the prompt box as a reference to their file. */
191export const PASTE_MAX_CHARS = 20_000
192
193/** What "into the prompt box" writes for an answer: the answer itself, or where to read it. */
194export const pasteAnswer = (o: { label: string; who: string; path: string; text: string }, m: Messages) =>
195  o.text.length <= PASTE_MAX_CHARS ? m.delegate.paste(o.label, o.who, o.path, o.text.trim()) : m.delegate.pasteRef(o.label, o.who, o.path)
196
hooks/sub5.ts 84 lines
1/**
2 * The Sub5 flow, as words: what the main agent is told when the Sub5 button is pressed (the brief is
3 * in the catalogs, one per language), and the worker agent type it dispatches. The exact git parts
4 * (base snapshot, applying, checking, cleaning) are bin/sub5.py's; the judgment (what to split, how to
5 * rank, whether a result meets the bar) is the main agent's. Kept free of `$` so tests can read it.
6 */
7import type { Locale, Messages } from './i18n'
8import { shq } from './shell'
9
10export const SUB5_AGENT = 'deckhand:sub5-worker'
11
12export type Sub5Options = {
13  /** At most this many items are dispatched at once. */
14  max: number
15  /** The workers' model and effort, as the agent type carries them. */
16  model: string
17  effort: string
18  /** Absolute path of bin/sub5.py. */
19  tool: string
20  note?: string
21  locale: Locale
22  attribution: boolean
23}
24
25/** The prefix every sub5.py command of the brief starts with: the tool speaks the brief's language. */
26export const sub5Run = (tool: string, locale: Locale) => `DECKHAND_LANG=${locale} python3 ${shq(tool)}`
27
28export const sub5Prompt = (o: Sub5Options, m: Messages): string =>
29  m.sub5.brief({
30    max: o.max,
31    model: o.model,
32    effort: o.effort,
33    run: sub5Run(o.tool, o.locale),
34    note: o.note,
35    languageName: m.languageName,
36    attribution: o.attribution,
37  })
38
39export const WORKER_DESCRIPTION =
40  'Parallel worker of the Sub5 flow: dispatched only by the main agent while it runs Sub5 (the user pressed the Sub5 button), to finish one assigned item in its own worktree. Not for ordinary delegation.'
41
42export const workerPrompt = (languageName: string, attribution: boolean) =>
43  [
44    'You are a Sub5 worker, one of up to five parallel workers the main agent dispatched at once. You work in your own git worktree (the current directory). The assignment gives you RUN, ITEM, BASE (a commit SHA), the goal, the files you may change, the acceptance criteria and the verification commands.',
45    '',
46    'Rules',
47    "1. Work only in your own worktree (the current directory). Do not read, write or operate on the main working tree, other worktrees or any remote; no push, no PR, no git config changes, never delete a branch or worktree (cleanup is the main agent's job).",
48    '2. First step, never skipped: align the worktree to BASE.',
49    '   BR=$(git branch --show-current); [ -n "$BR" ] || BR="sub5/<RUN>/<ITEM>"; git checkout -B "$BR" <BASE>',
50    '   The worktree is new, so this is safe. Then check `git rev-parse HEAD` equals BASE; if not, stop and report RESULT: blocked.',
51    "3. Do only the assigned item and change only the allowed files. If a file outside them must change, don't change it: explain why and what you suggest under NEEDS.",
52    "4. Verify yourself: run the given verification commands; without any, the project's usual tests, type check, lint or build that cover your change. Record the results honestly. Fix failures until they pass; if you can't, say so, never claim a pass.",
53    `5. Commit all changes on your branch (several commits are fine, project conventions${attribution ? ', no Co-Authored-By trailer and no "Generated with Claude Code" footer' : ''}). Before reporting, \`git status --porcelain\` must be empty.`,
54    '6. Leave nothing behind: no files outside the worktree; temporary files go in `.sub5-tmp/` inside the worktree and are deleted before reporting, never committed; no background processes (dev servers, watchers): stop any you started and note it under NOTES.',
55    '7. Never loosen a test, skip a check or change the acceptance criteria to make verification pass; put doubts under NEEDS.',
56    '',
57    `Report format (fixed field names; values in ${languageName})`,
58    'RESULT: ready | blocked',
59    'BRANCH: <git branch --show-current>',
60    'WORKTREE: <pwd>',
61    'HEAD: <git rev-parse HEAD>',
62    'BASE: <the BASE you were given>',
63    'FILES: <summary of git diff --stat BASE..HEAD>',
64    'VERIFIED: <commands run and results>',
65    'NOT_VERIFIED: <what was not verified and why; "none" if nothing>',
66    'NEEDS: <what the main agent must decide or handle; "none" if nothing>',
67    'NOTES: <for the integrator: risks, possible interactions with other items>',
68    '',
69    'When the main agent sends review comments: handle them point by point, verify again, commit again, and report again in the same format (decide RESULT afresh).',
70  ].join('\n')
71
72/** The agent type the Sub5 brief dispatches: the workers' model and effort are the person's to set. */
73export const workerSpec = (o: { model: string; effort: string; languageName: string; attribution: boolean }) => ({
74  name: 'sub5-worker',
75  description: WORKER_DESCRIPTION,
76  prompt: workerPrompt(o.languageName, o.attribution),
77  model: o.model,
78  effort: o.effort,
79  isolation: 'worktree' as const,
80  background: true as const,
81  // A worker neither fans out further nor moves between worktrees.
82  disallowedTools: ['Agent', 'EnterWorktree', 'ExitWorktree'],
83})
84
hooks/search.ts 51 lines
1/**
2 * The `search` tool: hands web research to the delegate the person chose in settings (Codex, Cursor
3 * agent or agy, read-only, through bin/delegate.py with `--web`), so the main model gets a sourced
4 * summary to check instead of running every search itself.
5 */
6
7export type SearchInput = {
8  question: string
9  context?: string
10  /** How recent the answer must be, in the asker's words (`2026`, `last 30 days`, `latest release`). */
11  freshness?: string
12}
13
14export const buildSearchPrompt = (input: SearchInput, languageName: string) =>
15  [
16    'You are a research assistant. Answer the QUESTION below by searching the web.',
17    '',
18    'RULES:',
19    '- Use only web search and reading web pages. Run no terminal command, and read or change no local file.',
20    '- Prefer primary sources: official documentation, release notes, standards, the vendor\'s own pages. Use other sources only to fill gaps, and say so.',
21    '- For anything that changes over time (versions, prices, availability, limits, dates), give each source\'s date and prefer the newest.',
22    '- Never invent a URL or a quote. Cite only pages you actually opened or saw in the results.',
23    '- When sources disagree, say which says what. Mark anything you could not confirm as "unverified".',
24    '- Keep product names, code, commands, numbers and units as they are.',
25    `- Write in ${languageName}.`,
26    '',
27    'ANSWER FORMAT:',
28    '1. The answer, in one to three sentences.',
29    '2. Key points, one line each, each ending with its source URL.',
30    '3. Conflicts or open questions, if any.',
31    '4. Sources: every URL used, with its date when the page shows one.',
32    'No preamble and no closing remarks.',
33    ...(input.freshness ? ['', `FRESHNESS: ${input.freshness}`] : []),
34    ...(input.context ? ['', `CONTEXT (why it is asked): ${input.context}`] : []),
35    '',
36    'QUESTION:',
37    '<<<',
38    input.question,
39    '>>>',
40  ].join('\n')
41
42export const SEARCH_SCHEMA = {
43  type: 'object',
44  properties: {
45    question: { type: 'string', description: 'What to find out, as a complete question that stands on its own.' },
46    context: { type: 'string', description: 'Why it is asked and what the answer is for, so the search aims right.' },
47    freshness: { type: 'string', description: 'How recent the answer must be, e.g. "2026", "last 30 days", "latest release".' },
48  },
49  required: ['question'],
50} as const
51
hooks/translate.ts 93 lines
1/**
2 * The `translate` tool: hands translation to the delegate the person chose in settings (Codex,
3 * Cursor agent or agy, through bin/delegate.py), with the localization standard spelled out, so the
4 * main model only reviews.
5 */
6
7export type TranslateInput = {
8  text: string
9  target: string
10  source?: string
11  context?: string
12  glossary?: readonly string[]
13}
14
15const SECRET = [
16  /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
17  /\b(sk|rk|pk)-[A-Za-z0-9_-]{20,}/,
18  /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}/,
19  /\bAKIA[0-9A-Z]{16}\b/,
20  /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
21  /\b[A-Z][A-Z0-9_]*(KEY|SECRET|TOKEN|PASSWORD|PASSWD)\s*[=:]\s*['"]?[^\s'"]{8,}/,
22]
23
24/** Secrets never go into a delegate's prompt. */
25export const findSecret = (text: string) => SECRET.find(pattern => pattern.test(text)) !== undefined
26
27const LOCALE_RULES: Record<string, string> = {
28  'zh-tw': [
29    '- Write Traditional Chinese as used in Taiwan (臺灣繁體中文), with full-width Chinese punctuation(,。:;「」).',
30    '- Use Taiwan vocabulary, never Mainland terms: 軟體 (not 軟件), 程式 (not 程序), 資料 (not 數據 for "data"), 伺服器 (not 服務器), 預設 (not 默認), 影片 (not 視頻), 網路 (not 網絡), 資訊 (not 信息), 介面 (not 界面), 設定 (not 設置), 品質 (not 質量), 支援 (not 支持 for "support" a feature), 登入 (not 登錄), 帳號 (not 賬號).',
31    '- Keep a space between Chinese and Latin words or numbers when it reads naturally in Taiwan tech writing.',
32  ].join('\n'),
33  'zh-hk': '- Write Traditional Chinese as used in Hong Kong, with Hong Kong vocabulary and punctuation.',
34  'zh-cn': '- Write Simplified Chinese as used in Mainland China, with Mainland vocabulary and punctuation.',
35  ja: '- Write natural Japanese as used in Japanese software and web products; UI strings in です/ます form unless the source is casual; katakana loanwords only where Japanese products actually use them.',
36  ko: '- Write natural Korean as used in Korean software and web products, polite 합니다/해요 style matching the source register.',
37}
38
39const rulesFor = (target: string) => {
40  const key = target.toLowerCase().replace('_', '-')
41  const exact = LOCALE_RULES[key]
42  if (exact) return exact
43  if (/^zh-(hant|tw)/.test(key) || key === 'zh-hant') return LOCALE_RULES['zh-tw']!
44  const base = LOCALE_RULES[key.split('-')[0]!]
45  return base ?? `- Follow the wording, spelling and punctuation conventions of the "${target}" locale as its native speakers use them in software and web products.`
46}
47
48export const buildPrompt = (input: TranslateInput) =>
49  [
50    `You are a senior software localizer. Translate the TEXT below${input.source ? ` from ${input.source}` : ''} into the "${input.target}" locale.`,
51    '',
52    'LOCALIZATION STANDARD (mandatory, overrides literal accuracy):',
53    '- Translate meaning, not words: use the expression native speakers of the target locale actually use in software UI, documentation and marketing copy.',
54    '- Never keep a literal dictionary sense when the source uses the word idiomatically. Example: English "fresh" meaning new/updated (fresh install, fresh look, fresh data) must become the locale\'s idiomatic word for new/latest (zh-TW: 全新、最新、重新), never the food sense (zh-TW: 新鮮).',
55    rulesFor(input.target),
56    '- Keep the source tone, register and roughly its length (UI strings stay short).',
57    '- Keep unchanged: code, inline code, commands, URLs, file paths, product and brand names, placeholders ({name}, {{var}}, %s, %d, $1, :param), ICU plural/select syntax, HTML/Markdown markup, and the line structure.',
58    '- If TEXT is JSON or a JS/TS object, return the same structure and keys, translating only the string values.',
59    ...(input.glossary?.length ? ['', 'GLOSSARY (use exactly these renderings):', ...input.glossary.map(g => `- ${g}`)] : []),
60    ...(input.context ? ['', `CONTEXT: ${input.context}`] : []),
61    '',
62    'Output ONLY the translation. No explanations, no notes, no surrounding quotes, no code fences unless TEXT has them.',
63    'Do not use any tools or run terminal commands.',
64    '',
65    'TEXT:',
66    '<<<',
67    input.text,
68    '>>>',
69  ].join('\n')
70
71/** The delegate prints the answer; strip a stray fence or the TEXT delimiters it may echo. */
72export const cleanOutput = (stdout: string, source: string) => {
73  let out = stdout.trim()
74  if (!/^```/.test(source.trim())) out = out.replace(/^```[a-zA-Z-]*\n([\s\S]*?)\n```$/, '$1')
75  return out.replace(/^<<<\n?/, '').replace(/\n?>>>$/, '').trim()
76}
77
78export const TRANSLATE_SCHEMA = {
79  type: 'object',
80  properties: {
81    text: { type: 'string', description: 'The text, i18n strings (JSON / TS object) or Markdown to translate.' },
82    target: { type: 'string', description: 'Target locale, e.g. zh-TW, ja, ko, en, de, fr, es.' },
83    source: { type: 'string', description: 'Source locale when it is not obvious.' },
84    context: { type: 'string', description: 'Where the text appears (UI button, blog post, landing page) and its audience.' },
85    glossary: {
86      type: 'array',
87      items: { type: 'string' },
88      description: 'Fixed renderings, one per entry, e.g. "workflow → 工作流程".',
89    },
90  },
91  required: ['text', 'target'],
92} as const
93