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…

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.
English · 繁體中文 · 简体中文 · 日本語 · 한국어
Install · The band · Delegate buttons · Settings · More tools · Privacy and safety · Sponsor · Changelog

<sub>Screenshots show the Traditional Chinese display language.</sub>
Sponsor Deckhand with USDT (TRC20) — one-time support only.
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:
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.
| Part | What it does |
|---|---|
| Usage gauges | 5-hour window, per-model weekly windows, weekly all-models window and context window |
O F S H | Switch the model to Opus, Fable, Sonnet or Haiku |
Sub5 | Split the current task into up to 5 items and run them in parallel sub agents |
cL cS cA cR gF | Hand one task to another AI CLI, read-only, and let Claude review the answer |
Recap | Explain 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.
5h: the 5-hour window.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.
O Opus · F Fable · S Sonnet · H Haiku
/model <name> into the prompt box. You press Enter, and the app's own model menu stays in sync.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.
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.
| Button | CLI | Model | Effort |
|---|---|---|---|
cL | Codex | GPT-6 Luna | max |
cS | Codex | GPT-6.1 Sol | medium |
cA | Codex | GPT-6 Astra | medium |
cR | Cursor agent | Grok 4.7 | high |
gF | agy | Gemini 3.8 Flash | high |
These are the defaults. In settings you can edit every target: label, CLI, model ID, effort, name, and on/off.
-s read-only, Cursor agent with --mode ask, and agy runs headless, which denies tools automatically.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./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.[!NOTE] Delegating sends the brief to the provider behind that CLI.
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.
The ⚙ button at the right end of the band, or /deckhand, opens the settings pane. It has five tabs:
| Tab | What you set |
|---|---|
| General | Display language, which buttons show, the usage warning threshold |
| Delegates | Each delegate target: on/off, label, CLI, model ID, effort, name |
| Translate & search | Whether translation and web search go to a delegate, and which one (cL … gF). Both are off by default. |
| Sub5 | The workers' model, effort and maximum number of items |
| Advanced | CLI paths and the Codex home folder, the guards, the deploy watch, reset to defaults |

Settings are stored per user.
English, 繁體中文, 简体中文, 日本語 and 한국어. By default the display language follows Claude Code's language setting. You can also pick a language in settings.
| Tool | What it does |
|---|---|
/watch-deploy and the watch_deploy tool | Watch 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, /codex | Hand 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 guard | Strips 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 guard | Stops 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. |
translate | Localized 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 ⚙. |
search | Web 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 ⚙. |
gh for watches; the codex, Cursor agent and agy CLIs for the delegate buttons.What leaves your machine:
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:
(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.
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 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.
hooks/register.tsx 1854 lines1import { 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 lines1/**
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]
112hooks/delegate.ts 86 lines1/**
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 })
86hooks/fingerprint.ts 200 lines1/**
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(/ | /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}
200hooks/guard.ts 114 lines1/**
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}
114hooks/i18n.ts 56 lines1/**
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')
56hooks/settings.ts 180 lines1/**
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}
180hooks/state.ts 7 lines1/** `$.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))
7hooks/runs.ts 196 lines1/**
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)
196hooks/sub5.ts 84 lines1/**
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})
84hooks/search.ts 51 lines1/**
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
51hooks/translate.ts 93 lines1/**
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