A fishStack pane for Claude Code: a perm worktree with a read-only head start, crew dials and a context library.

<img src="assets/icon.svg" width="84" height="84" alt="fishStack's mark: two pale koi circling a ripple on night blue">
<h1 align="center">fishStack</h1>
<b>A Claude Code plugin: a worktree for every session, a working style you set once,<br> and notes the agent always reads.</b>
<a href="https://www.npmjs.com/package/claude-fish-stack-mod"><img src="https://img.shields.io/npm/v/claude-fish-stack-mod?color=2fcfa6&label=npm" alt="npm version"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-8f9cff" alt="MIT license"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.296%2B-d97757" alt="Claude Code 2.1.296 or newer">
<img src="docs/media/rail.gif" width="100%" alt="The fishStack rail above the prompt: prompt cache bars running down, the cost, the dials pill turning amber while Autopilot waits on a question, then green once it answered">
If you run Claude Code all day, a few things keep getting in the way:
node_modules.fishStack adds a status rail above the prompt, inside the chat: the prompt cache's heat bar, the cost, and where the agent works and how (⎇ fish/abcdef, ◇ Balanced, ▤ 3 of 4 on). Click one of those three and its card opens right under the rail, above the prompt; click again to close it. Each card starts with its switch (Use worktrees, Use dials, Use context), so you can turn any of them off.
Every setting is kept per repository: the switches, Enforce, the dials, Ask each time or Always a worktree, and the context library. A worktree shares its main checkout's settings; outside git, the folder has its own. A repository that hasn't set something yet starts from the value you had before settings were per repository.
| Card | What it does | | - | - | | Worktrees | A new session can move into a worktree of its own, already set up, typically in about three seconds. | | Dials | Six dials set how the agent plans, delegates, persists, asks, proves and talks. Some levels are enforced in code, not only asked for. | | Context | Notes, links and files the agent reads in every request. Mark one Strict and the agent treats it as ground truth. |
In Claude Code 2.1.296 or newer:
/plugin marketplace add patrick-lai/claude-fish-stack-mod
/plugin install fishstack@claude-fish-stack-mod
Or the same from your shell:
claude plugin marketplace add patrick-lai/claude-fish-stack-mod
claude plugin install fishstack@claude-fish-stack-mod
Or in one step, with no /plugin commands:
npx -y fishstack-mod install
npx -y fishstack-mod uninstall
Restart Claude Code. The rail shows above the prompt; click an item, or type /fs dials (or /fishstack dials), to open a card. /fs panel opens all three cards as a separate pane instead, docked beside the chat where Claude Code docks panes.
fishStack is built and tested on macOS: copy-on-write cloning and the file pickers use macOS tools.
<img src="docs/media/claim.gif" width="100%" alt="Real Claude Code in a terminal: a new session claims a warm slot, moves in on its own branch, and answers a question">
Turn on Use worktrees and a new session can have a worktree of its own, on its own fish/… branch, kept around to reuse. Your main checkout stays untouched. It is off until you turn it on.
Plain claude in a main checkout doesn't say whether you want one, so fishStack asks once at the start:
| Answer | What happens | | - | - | | A worktree | A branch of its own from the latest origin, read-only for the few seconds until it moves in | | This main checkout | It works right there, on whatever branch is checked out (the dialog names it), with full access | | Always a worktree | A worktree now, and for every new terminal session without asking. New terminal sessions: Ask each time in the Worktree card brings the question back |
The pool keeps warming while the question is open, so a worktree is still quick to take, and Create worktree now in the Worktree card changes your mind later. Autopilot never answers this question for you. claude --worktree is an answer already, so it is not asked.
It's fast because the work happens before you need it:
node_modules is cloned copy-on-write (APFS), so a slot costs almost no disk until something changes.refs/fish/backup/ first.main (or master) locks the main checkout out of its own default branch. Inside a fishStack worktree, the guard refuses git switch or git checkout onto the default branch and points to origin/<default> instead (git switch -c <branch> origin/main). A slot that still ends its session on the default branch is detached as it is released.It works with Claude Code's own worktrees too. claude --worktree feat-x lands in a slot on fish/feat-x. In the desktop app, its worktree option decides. Ticked, the session stays in the worktree the app made, which fishStack sets up in place (dependencies cloned, setupCommand run, read-only until then), so the app's diff, PR and archive keep following it. Unticked, the session stays in the main checkout (Create worktree now still claims a slot).
In a big repository the desktop's own worktree is the slow part: a full checkout for every session, and it refuses to make one at all with less free disk than 2.5 times the repository's git pack. Give the desktop the pool's slots instead. Its WorktreeCreate hook then takes the slot fishStack keeps offered (clean, detached, dependencies in place), holds it for the session, checks out the session's branch from origin and hands it over. The desktop tracks that slot as the session's worktree, so its diff, PR and archive follow it. When the desktop removes the worktree, the hold goes and the slot returns to the pool. With no slot offered, the hook makes a new one, as slow as the desktop's own.
It sets itself up. The first session in a repository with Use worktrees on keeps the hook beside the repository's git data (.git/fishstack/) and wires it into the repository's .claude/settings.local.json, your own settings for it, kept out of git (added to the repository's local info/exclude if nothing ignores it). Everything else in that file stays, a toast says it happened, and turning Use worktrees off takes fishStack's entries out again. A file that doesn't parse, or a WorktreeCreate hook of your own there, is left alone.
Every Claude Code client in the repository reads those settings, so the hook decides at each call who it is for:
| Caller | Gets | | - | - | | The desktop app, fishStack enabled, Use worktrees on | A slot from the pool | | The CLI with fishStack | A slot: fishStack answers its worktree creation itself, before the hook (plain claude asks first) | | Anything else: ACP, the SDK, the CLI without fishStack | The worktree Claude Code makes itself, .claude/worktrees/<name> on worktree-<name> from origin's default branch | | Any client once fishStack is disabled or Use worktrees is off | Claude Code's own worktree, and the hook takes its entries out of the settings |
The desktop runs the hook only for a session a person starts, so a fork or a session Claude starts in that repository gets no worktree. A hook replaces .worktreeinclude, so those files aren't copied. e2e/desktop-hook.sh checks the hook against a throwaway repository.
<img src="docs/media/worktree-desktop.png" width="760" alt="The Worktrees tab in the desktop app: Use worktrees on, the pool's status, this session's slot and branch, and the slots list">
git fetch origin <default>. If that fails, it starts from the local branch and says so.mkdir, broken after 30 s if its holder died), it takes the slot this session already holds (a retry), an idle slot, or makes the next one. A new slot is git worktree add --detach --no-checkout, then reset --hard with core.checkStat=minimal, then an index warm-up. A used slot is scrubbed: uncommitted work goes to refs/fish/backup/<slot>-<time>, then reset --hard, clean -fd (ignored files such as node_modules stay) and checkout -B fish/<name>. The name is the session's id, or the Claude Code worktree it was adopted from; if a branch by that name already exists (Claude Code reuses a worktree folder for a new session), the slot gets fish/<name>-<session id> instead, so no other session's branch is ever reset. Git hooks never run on these writes.node_modules (up to six folders deep) is cloned with cp -c into a staging folder, then renamed into place. The source is an idle sibling slot with the same lockfiles, or else the main checkout. Then setupCommand runs, unless this slot already ran it with these lockfiles.EnterWorktree) as soon as it is ready, even mid-turn. The agent always knows where it is: a head start that began after the prompt (an app attaching late) is told after its next tool call, and so is a move it was not warned of. Slots live under the repository's .claude/worktrees/, where Claude Code keeps its own, so the move needs no confirming. Set worktreeRoot elsewhere and Claude Code asks you to confirm each move.A slot being made is marked in the store, so every session lists it as setting up and none claims it half made. Removing a slot leases it first, saves anything uncommitted under refs/fish/backup/, then moves it out of the pool and deletes it; its branch stays. The pool stops warming when free disk drops under minFreeGb (20 GB), and the card says why. During the head start, the guard refuses Edit, Write, MultiEdit, NotebookEdit and ExitPlanMode. It also refuses any shell command that is not a known read (ls, cat, rg, read-only git, sed -n and the like). A redirect anywhere but /dev/null counts as a write.
<img src="docs/media/dials.gif" width="100%" alt="The Dials tab in the desktop app: the radar reshapes as the presets change from Balanced to Quick, Deep work and Autopilot">
Six dials, each with five levels, become a short Working style brief in the agent's system prompt:
| Dial | 1 | 2 · 3 · 4 | 5 | | - | - | - | - | | Foresight | Dive in | Sketch · Plan steps · Write a spec | Deep spec | | Crew | Solo | Scouts · Helpers · Squads | Swarm | | Tenacity | One pass | Retry once · Push through · Checklist | Goal mode | | Autonomy | Ask first | Check in · When unsure · Decide, note | Autopilot | | Proof | Trust | Compile · Run tests · Component QA | Full QA | | Voice | Silent | Terse · Normal · Explain | Teach |
Start from a preset (Balanced, Quick, Deep work, Autopilot), then nudge single dials. On the desktop you can drag the radar's points. The estimates beside the radar show what a setting costs you in spend, time to done, and how often the agent will ask you.
A change takes effect right away, even mid-turn: the agent picks it up before its next request. What agents are told shows the exact brief. Use dials off sends no working style at all, and your settings wait for you.
A prompt is a request; some levels are also held in code, so the agent can't skip them. Each dial's card says what its level enforces:
| Dial | Level | Enforced | | - | - | - | | Foresight | Write a spec, Deep spec | The first edit of a request waits until the agent has posted its spec. | | Crew | Solo, Scouts | Subagents are refused; at Scouts, one per request. | | Tenacity | Checklist, Goal mode | A request that changed code ends with its checklist or acceptance criteria. | | Proof | Compile and up | A request that changed code runs a build, typecheck or tests after its last change. | | Voice | Explain, Teach | A request that changed code ends with the labelled parts. | | Autonomy | Autopilot | A question nobody answers is settled after 15 minutes. |
It's built never to get in the way of Claude Code itself:
The level prompts borrow good habits from pStack (Lauren Tan, MIT): verdicts of VERIFIED, NOT VERIFIED or INCONCLUSIVE, a failing test before a bug fix, one done-predicate and one owner per file for parallel subagents, and testing a shared assumption after two failed fixes.
<img src="docs/media/autopilot.gif" width="100%" alt="Real Claude Code: on Autopilot the agent asks a question, a line under it counts down, nobody answers, and Autopilot picks the recommended option">
Autopilot is the top Autonomy level: the agent works as if you're away. It decides open questions itself, clears its own blockers, may use other apps and connected tools, and never deletes data.
If it still asks you something and nobody answers, the question isn't left hanging overnight. After 15 minutes (autoAnswerMinutes), Autopilot picks the option marked (Recommended), or the first one. The agent is told that no person chose, and carries on.
While Autopilot waits, the dials pill on the rail turns amber with a ring that runs down, the Dials card says Answers in N min, and a line under the question says what it will pick. Answer yourself, or lower the dial, and nothing is picked.
<img src="docs/media/context.gif" width="100%" alt="Real Claude Code: a note added in the Context tab and marked Strict, then the agent names a new field in snake_case because of it">
Add the things the agent should always know (Use context off holds them all back at once): a note, a link (fetched as text), a file, or a whole folder from Add folder…. Each source has an On/Off switch. A Strict source is binding: if the code disagrees, the agent has to say so. A line keeps the total near a 12k-token budget, and the card's last line shows how full the context window is.
Libraries are kept per repository and reach the agent as a Project context section, every request.
<img src="docs/media/context-desktop.png" width="760" alt="The Context tab in the desktop app: a folder group and notes, each with its switch, the token meter and the context window">
The rail is one row of pills above the prompt, and it stays one row at any window width. In the desktop app, hover a pill for details (nothing shifts). Click Dials, Context or Worktree to open that card under the rail.
| Pill | Shows | | - | - | | Mark | Opens the Worktree card, or closes the open one. | | Cache | The prompt cache. Five bars run down over the five minutes after a reply. While any are lit, your next message reuses the cache, so it's faster and cheaper. Hover for the hit rate: the last reply's and the whole session's share of input read from the cache, from the token counts Claude reports. | | Cost | Claude Code's own /cost total at API list prices, cache reads and writes included. It isn't your plan's bill. Tokens in and out on hover. | | Dials | The preset, or Off. Amber while Autopilot waits to answer for you, green once it has. | | Context | How many sources are on, or Off. | | Worktree | Where this session works: its branch, Preparing…, or the main checkout. |
In the terminal the rail is plain text, and the same items open the same cards.
Set them in /config. They're stored under pluginConfigs["fishstack"].options.
| Option | Default | What it does | | - | - | - | | autoWorktree | false | Use worktrees' starting value. Once you flip the switch in a repository, your choice sticks there. | | worktreeRoot | empty | Where slots live. Empty: <repo>/.claude/worktrees/fishstack/<n>, added to the repository's local info/exclude if nothing ignores it yet. Set: <root>/<repo>-<hash>/<n>, and Claude Code asks before each move in. | | setupCommand | empty | Runs in a new slot before the session moves in, pnpm install say. Times out after 10 minutes. | | minFreeGb | 20 | The pool stops warming below this much free disk. | | autoAnswerMinutes | 15 | How long Autopilot waits before answering for you. |
fishStack was called Craftshop. Its first session copies what Craftshop saved (dials, context libraries, the worktrees switch) into its own store, once, and never over anything fishStack already holds. Options set under pluginConfigs["craft-shop"] don't carry over, so set them again in /config.
claude plugin test .
That runs the tests in tests/, drawing the UI on both the terminal and the desktop. More:
claude plugin validate . # manifest, marketplace and hooks module
claude --plugin-dir . # run it; saving hot-reloads
e2e/drive.sh # end to end against real Claude Code in tmux
e2e/shoot.sh # every desktop tab, screenshotted
e2e/reel/scenes.sh # record this README's terminal scenes from real Claude Code
e2e/reel/media.sh # draw every picture in docs/media
e2e/drive.sh checks every feature as a person would, against a fixture repository: the pool warms, a session claims a slot and its writes land there, the next session reuses it with a backup, dials (mid-turn too) and a Strict note reach the agent, Autopilot answers an unanswered question, and claude --worktree comes up in a slot.
The README's media come from two places:
claim, autopilot, context): real Claude Code with this plugin, recorded in tmux by e2e/reel/scenes.sh.rail, dials, the two panes): the desktop's own UI trees, drawn by the tests in tests/media.test.ts and tests/desktop.test.ts and laid out as the desktop pane. These are close to the app's own paint, not screenshots of it."Moonlit": two pale koi circling a ripple on night water, a school of two. It's drawn in code in hooks/brand.ts, and node e2e/brand.mjs writes assets/icon.svg and a one-colour menu-bar version, assets/icon-template.svg.
MIT © Patrick Lai
hooks/register.tsx 18 lines1import type { Register } from 'claude-code'
2
3import { registerContext } from './context'
4import { registerDials } from './dials'
5import { registerGuard } from './guard'
6import { registerPane } from './pane'
7import { registerSession } from './session'
8import { registerWorktree } from './worktree'
9
10export const register: Register = (on, options) => {
11 registerGuard(on)
12 registerSession(on, options)
13 registerWorktree(on, options)
14 registerDials(on)
15 registerContext(on)
16 registerPane(on)
17}
18hooks/context.tsx 630 lines1// The Context tab, after CommissionAI's context library: the sources the user
2// shares with the agent (notes, links, files from the repository), each On or
3// Off and On ones optionally Strict, kept loose or grouped in folders that
4// switch as one. The live context window sits in a footer. session.ts puts
5// the sources that are on in the system prompt.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, On } from 'claude-code'
8
9import type { ContextView, Folder, Source, SourceMode, Tab } from '../types'
10import { switchArt } from './art'
11import { drawnButton, glyph, GLYPH_COLOR, isRich, svg, switchRow } from './kit'
12import { setSetting, settingsHome } from './settings'
13import { paneHeads } from './state'
14import { bar, frame, PANE, resolveUi, tokens } from './ui'
15import type { Ui } from './ui'
16
17const contextDraft = atom({ plugin: 'fishstack', key: 'contextDraft' } as const, { input: '' })
18const contextStatus = atom({ plugin: 'fishstack', key: 'contextStatus' } as const, '')
19const contextView = atom({ plugin: 'fishstack', key: 'contextView' } as const, {
20 openFolders: [],
21 openId: null,
22 picking: null,
23 deletingFolder: null,
24 isEditingText: false,
25})
26const folders = atom({ plugin: 'fishstack', key: 'folders' } as const, [])
27const isTurnRunning = atom({ plugin: 'fishstack', key: 'isTurnRunning' } as const, false)
28const isWindowShown = atom({ plugin: 'fishstack', key: 'isWindowShown' } as const, false)
29const sources = atom({ plugin: 'fishstack', key: 'sources' } as const, [])
30const tab = atom({ plugin: 'fishstack', key: 'tab' } as const, null)
31const isClientDown = atom({ plugin: 'fishstack', key: 'isClientDown' } as const, false)
32const isContextOn = atom({ plugin: 'fishstack', key: 'isContextOn' } as const, true)
33
34// The Use context switch, kept across sessions for each repository. Off, no source reaches the
35// agent, whatever each source's own switch says; the library is kept.
36export const CONTEXT_ON_KEY = 'useContext'
37
38async function setContextOn($: EngineInterface, isOn: boolean): Promise<void> {
39 await setSetting($, CONTEXT_ON_KEY, isOn)
40 await update($, isContextOn, () => isOn)
41}
42
43type $ = EngineInterface
44
45export const CONTEXT_SECTION_ID = 'fishstack:context'
46const CHAR_CAP = 12_000
47const TOKEN_BUDGET = 12_000
48const IMPORT_CAP = 40
49const TEXT_FILE = /\.(md|mdx|markdown|txt|rst|adoc|json|ya?ml|toml|graphql|proto|sql|csv)$/i
50const SKIPPED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'vendor', 'target'])
51
52
53const KIND_LABEL: Record<Source['kind'], string> = { note: 'Note', link: 'Link', file: 'File' }
54
55// What a source looks like in the library, and what it costs the agent.
56export function libraryStoreKey(root: string): string {
57 return `context:${root}`
58}
59
60// The store key the library used before folders, read once to carry it over.
61export function legacyStoreKey(root: string): string {
62 return `sources:${root}`
63}
64
65export function parseLibrary(stored: unknown, legacy: unknown): { sources: Source[]; folders: Folder[] } {
66 const library = stored as { sources?: unknown; folders?: unknown } | undefined
67 if (library !== undefined && library !== null && Array.isArray(library.sources)) {
68 return {
69 sources: (library.sources as Source[]).map(normalize),
70 folders: Array.isArray(library.folders) ? (library.folders as Folder[]) : [],
71 }
72 }
73 return { sources: Array.isArray(legacy) ? (legacy as Source[]).map(normalize) : [], folders: [] }
74}
75
76function normalize(one: Source): Source {
77 return { ...one, path: one.path ?? null, folderId: one.folderId ?? null }
78}
79
80function approxTokens(chars: number): number {
81 return Math.ceil(chars / 4)
82}
83
84function chars(count: number): string {
85 return count >= 1_000 ? `${(count / 1_000).toFixed(1)}k chars` : `${count} chars`
86}
87
88// A title cut to fit its row at a word, the whole one kept in the store.
89function shorten(text: string, room: number): string {
90 return text.length <= room ? text : `${text.slice(0, room - 1).replace(/\s+\S*$/, '').replace(/[.,;:]$/, '')}…`
91}
92
93function host(url: string): string {
94 return /^https?:\/\/([^/]+)/.exec(url)?.[1] ?? url
95}
96
97function facts(source: Source): string {
98 if (source.kind === 'link' && source.url !== null) return `Link · ${host(source.url)} · ${chars(source.text.length)}`
99 if (source.kind === 'file' && source.path !== null) return `File · ${source.path} · ${chars(source.text.length)}`
100 return `${KIND_LABEL[source.kind]} · ${chars(source.text.length)}`
101}
102
103// One mode for a folder's members, or 'mixed' when they differ.
104export function folderMode(members: Source[]): SourceMode | 'mixed' {
105 if (members.length === 0) return 'off'
106 const first = members[0]!.mode
107 return members.every(one => one.mode === first) ? first : 'mixed'
108}
109
110function htmlToText(html: string): { title: string | null; text: string } {
111 const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim() ?? null
112 const text = html
113 .replace(/<(script|style|noscript|svg)[\s\S]*?<\/\1>/gi, ' ')
114 .replace(/<br\s*\/?>|<\/(p|div|li|h[1-6]|tr|section|article)>/gi, '\n')
115 .replace(/<[^>]+>/g, ' ')
116 .replace(/ /g, ' ')
117 .replace(/</g, '<')
118 .replace(/>/g, '>')
119 .replace(/"/g, '"')
120 .replace(/'/g, "'")
121 .replace(/&/g, '&')
122 .replace(/[ \t]+/g, ' ')
123 .replace(/\s*\n\s*/g, '\n')
124 .trim()
125 return { title, text }
126}
127
128export function contextSection(list: Source[], groups: Folder[] = []): string | null {
129 const enabled = list.filter(one => one.mode !== 'off')
130 if (enabled.length === 0) return null
131 const folderName = new Map(groups.map(one => [one.id, one.name]))
132 const blocks = enabled.map(one => {
133 const group = one.folderId === null ? undefined : folderName.get(one.folderId)
134 const heading = group === undefined ? one.title : `${group} › ${one.title}`
135 const origin = one.url !== null ? ` (${one.url})` : one.path !== null ? ` (${one.path})` : ''
136 const weight =
137 one.mode === 'strict'
138 ? 'STRICT: binding ground truth. Follow it over your own assumptions, and say so if the code disagrees.'
139 : 'Guidance: weigh it, but the code and the user win when they disagree.'
140 return `### ${heading}${origin}\n${weight}\n\n${one.text}`
141 })
142 return ['## Project context the user shared', ...blocks].join('\n\n')
143}
144
145// The thresholds CommissionAI's ContextRing turns amber and red at.
146function fillColor(percent: number): string {
147 return percent >= 85 ? 'error' : percent >= 65 ? 'warning' : 'success'
148}
149
150async function save($: $): Promise<void> {
151 await $.store.set(libraryStoreKey(await settingsHome($)), {
152 sources: await read($, sources),
153 folders: await read($, folders),
154 })
155}
156
157async function saveSources($: $, change: (list: Source[]) => Source[]): Promise<void> {
158 await update($, sources, change)
159 await save($)
160}
161
162async function newId($: $): Promise<string> {
163 return `${(await $.clock.now()).toString(36)}-${crypto.randomUUID().slice(0, 4)}`
164}
165
166const VIEW: ContextView = {
167 openFolders: [],
168 openId: null,
169 picking: null,
170 deletingFolder: null,
171 isEditingText: false,
172}
173
174// The view as this version draws it. $.state outlives a reload, so a view
175// saved by an older version can lack fields; they start at their defaults.
176async function readView($: $): Promise<ContextView> {
177 return { ...VIEW, ...(await read($, contextView)) }
178}
179
180async function updateView($: $, change: (now: ContextView) => ContextView): Promise<void> {
181 await update($, contextView, now => change({ ...VIEW, ...now }))
182}
183
184async function setView($: $, change: Partial<ContextView>): Promise<void> {
185 await updateView($, now => ({ ...now, ...change }))
186}
187
188async function say($: $, text: string): Promise<void> {
189 await update($, contextStatus, () => text)
190}
191
192async function addSource($: $, source: Omit<Source, 'id' | 'mode' | 'folderId'>): Promise<Source> {
193 const added: Source = { ...source, id: await newId($), mode: 'guidance', folderId: null }
194 await saveSources($, list => [...list, added])
195 return added
196}
197
198async function fetchLink($: $, url: string): Promise<{ title: string; text: string } | string> {
199 let response
200 try {
201 response = await $.http.fetch(url)
202 } catch (error) {
203 $.ui.log(`fetch ${url} failed: ${String(error)}`, { to: 'debug' })
204 return 'Could not reach that link.'
205 }
206 if (!response.ok) return `That link answered ${response.status}.`
207 const isHtml = (response.headers['content-type'] ?? '').includes('html')
208 const page = isHtml ? htmlToText(response.text) : { title: null, text: response.text.trim() }
209 return { title: page.title || url.replace(/^https?:\/\//, '').replace(/\/$/, ''), text: page.text.slice(0, CHAR_CAP) }
210}
211
212async function readText($: $, path: string): Promise<string> {
213 const text = await $.fs.read(path)
214 return typeof text === 'string' ? text.slice(0, CHAR_CAP) : ''
215}
216
217// The text files under a directory, breadth first, at most IMPORT_CAP, as
218// absolute paths.
219async function textFiles($: $, directory: string): Promise<string[]> {
220 const found: string[] = []
221 const queue = [directory]
222 while (queue.length > 0 && found.length < IMPORT_CAP) {
223 const dir = queue.shift()!
224 const entries = await $.fs.list(dir)
225 for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
226 if (entry.name.startsWith('.')) continue
227 const path = `${dir}/${entry.name}`
228 if (entry.kind === 'dir' && !SKIPPED_DIRS.has(entry.name)) queue.push(path)
229 else if (entry.kind === 'file' && TEXT_FILE.test(entry.name) && found.length < IMPORT_CAP) found.push(path)
230 }
231 }
232 return found
233}
234
235// The one add field: a link is fetched, a path in the repository is read (a
236// directory becomes a folder of its text files), anything else is a note.
237async function addFromInput($: $, raw: string): Promise<void> {
238 const text = raw.trim()
239 if (text === '') return say($, 'Paste a link or write a note, or choose files.')
240 if (/^https?:\/\/\S+$/.test(text)) {
241 await say($, `Fetching ${text}`)
242 const page = await fetchLink($, text)
243 if (typeof page === 'string') return say($, page)
244 await addSource($, { kind: 'link', title: page.title, text: page.text, url: text, path: null })
245 return clearInput($, `Added ${page.title}`)
246 }
247 const looksLikePath = !/\s/.test(text) && /[/.]/.test(text)
248 if (looksLikePath) {
249 const path = text.replace(/^\.\//, '').replace(/\/+$/, '')
250 const top = await $.session.root()
251 const stat = await $.fs.stat(`${top}/${path}`).catch(() => undefined)
252 if (stat?.kind === 'dir') return importDirectory($, `${top}/${path}`, path)
253 if (stat?.kind === 'file') {
254 const body = await readText($, `${top}/${path}`).catch(() => null)
255 if (body === null) return say($, `Could not read ${path}.`)
256 await addSource($, { kind: 'file', title: path.split('/').pop() ?? path, text: body, url: null, path })
257 return clearInput($, `Added ${path}`)
258 }
259 }
260 const firstLine = text.split('\n')[0]!
261 const title = firstLine.slice(0, 120)
262 await addSource($, { kind: 'note', title, text, url: null, path: null })
263 return clearInput($, 'Added a note. Open it to rename it or add more.')
264}
265
266// The macOS file dialog, run through osascript on the machine Claude Code
267// runs on: the picked paths, [] when the person cancels, null when there is
268// no dialog to show (not macOS, or osascript failed).
269async function pickPaths($: $, kind: 'file' | 'folder'): Promise<string[] | null> {
270 const top = (await $.session.root()).replace(/"/g, '')
271 const chooser =
272 kind === 'file'
273 ? `choose file with prompt "Add files to fishStack context" default location (POSIX file "${top}") with multiple selections allowed`
274 : `choose folder with prompt "Add folders to fishStack context" default location (POSIX file "${top}") with multiple selections allowed`
275 const ran = await $.process
276 .run(['osascript', '-e', 'activate', '-e', `set picked to ${chooser}`, '-e', 'set out to ""', '-e', 'repeat with one in picked', '-e', 'set out to out & POSIX path of one & linefeed', '-e', 'end repeat', '-e', 'return out'], { timeoutMs: 600_000 })
277 .catch(() => null)
278 if (ran === null) return null
279 if (ran.exitCode !== 0) return /-128/.test(ran.stderr) ? [] : null
280 return ran.stdout
281 .split('\n')
282 .map(line => line.trim().replace(/\/$/, ''))
283 .filter(line => line !== '')
284}
285
286// A path as a source keeps it: relative to the checkout when inside it.
287function relativePath(top: string, path: string): string {
288 return path.startsWith(`${top}/`) ? path.slice(top.length + 1) : path
289}
290
291// Set before the first await, so a second press while the dialog is coming
292// up (it can take a few seconds) is dropped rather than opening another.
293let isPicking = false
294
295async function choose($: $, kind: 'file' | 'folder'): Promise<void> {
296 if (isPicking) return
297 isPicking = true
298 try {
299 await setView($, { picking: kind })
300 await say($, 'Waiting for the file dialog. It can take a moment to open, sometimes behind this window.')
301 await addPicked($, kind, await pickPaths($, kind))
302 } finally {
303 isPicking = false
304 await setView($, { picking: null })
305 }
306}
307
308async function addPicked($: $, kind: 'file' | 'folder', picked: string[] | null): Promise<void> {
309 if (picked === null) return say($, 'Could not open the file dialog here. Type a path in the field instead.')
310 if (picked.length === 0) return say($, '')
311 const top = await $.session.root()
312 const added: string[] = []
313 for (const path of picked) {
314 if (kind === 'folder') {
315 await importDirectory($, path, relativePath(top, path))
316 added.push(relativePath(top, path))
317 continue
318 }
319 const body = await readText($, path).catch(() => null)
320 if (body === null || body.includes('\u0000')) {
321 await say($, `Skipped ${path.split('/').pop()}: only text files can be sources.`)
322 continue
323 }
324 await addSource($, { kind: 'file', title: path.split('/').pop() ?? path, text: body, url: null, path: relativePath(top, path) })
325 added.push(path.split('/').pop() ?? path)
326 }
327 if (kind === 'file' && added.length > 0) await say($, `Added ${added.join(', ')}`)
328}
329
330async function clearInput($: $, status: string): Promise<void> {
331 await update($, contextDraft, now => ({ ...now, input: '' }))
332 await say($, status)
333}
334
335async function importDirectory($: $, directory: string, path: string): Promise<void> {
336 const top = await $.session.root()
337 const files = await textFiles($, directory)
338 if (files.length === 0) return say($, `No text files under ${path}.`)
339 const folder: Folder = { id: await newId($), name: path.split('/').pop() || path }
340 const added: Source[] = []
341 for (const file of files) {
342 added.push({
343 id: await newId($),
344 kind: 'file',
345 title: file.split('/').pop() ?? file,
346 text: await readText($, file),
347 url: null,
348 path: relativePath(top, file),
349 mode: 'guidance',
350 folderId: folder.id,
351 })
352 }
353 await update($, folders, list => [...list, folder])
354 await saveSources($, list => [...list, ...added])
355 await updateView($, now => ({ ...now, openFolders: [...now.openFolders, folder.id] }))
356 await clearInput($, `Added ${path} as a folder of ${files.length} files`)
357}
358
359async function refresh($: $, source: Source): Promise<void> {
360 await say($, `Refreshing ${source.title}`)
361 let text: string | null = null
362 if (source.kind === 'link' && source.url !== null) {
363 const page = await fetchLink($, source.url)
364 if (typeof page === 'string') return say($, page)
365 text = page.text
366 } else if (source.kind === 'file' && source.path !== null) {
367 const file = source.path.startsWith('/') ? source.path : `${await $.session.root()}/${source.path}`
368 text = await readText($, file).catch(() => null)
369 }
370 if (text === null) return say($, `Could not refresh ${source.title}.`)
371 await saveSources($, list => list.map(one => (one.id === source.id ? { ...one, text: text! } : one)))
372 await say($, `Refreshed ${source.title}`)
373}
374
375async function setMode($: $, id: string, mode: SourceMode): Promise<void> {
376 await saveSources($, list => list.map(one => (one.id === id ? { ...one, mode } : one)))
377}
378
379// A folder's switch: everything on (strict members stay strict), or all off.
380async function setFolderOn($: $, folderId: string, isOn: boolean): Promise<void> {
381 await saveSources($, list =>
382 list.map(one => (one.folderId !== folderId ? one : { ...one, mode: isOn ? (one.mode === 'off' ? 'guidance' : one.mode) : 'off' })),
383 )
384}
385
386async function renameSource($: $, id: string, title: string): Promise<void> {
387 if (title.trim() === '') return
388 await saveSources($, list => list.map(one => (one.id === id ? { ...one, title: title.trim() } : one)))
389}
390
391async function editNote($: $, id: string, text: string): Promise<void> {
392 await saveSources($, list => list.map(one => (one.id === id ? { ...one, text } : one)))
393 await setView($, { isEditingText: false })
394}
395
396async function removeSource($: $, id: string): Promise<void> {
397 await saveSources($, list => list.filter(one => one.id !== id))
398 await setView($, { openId: null })
399}
400
401async function deleteFolder($: $, id: string): Promise<void> {
402 await update($, sources, list => list.filter(one => one.folderId !== id))
403 await update($, folders, list => list.filter(one => one.id !== id))
404 await save($)
405 await updateView($, now => ({ ...now, deletingFolder: null, openFolders: now.openFolders.filter(one => one !== id) }))
406}
407
408async function toggleFolderOpen($: $, id: string): Promise<void> {
409 await updateView($, now => ({
410 ...now,
411 openFolders: now.openFolders.includes(id) ? now.openFolders.filter(one => one !== id) : [...now.openFolders, id],
412 }))
413}
414
415export function registerContext(on: On): void {
416 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
417 if ((await read($, tab)) !== 'context') return next(e)
418 const ui: Ui = resolveUi($.ui.resolve(e), e.surface, await read($, isClientDown))
419 return frame(ui, 'context', e.props.bodyColumns, id => void update($, tab, () => id), await contextBody($, ui, e.props.bodyColumns), await paneHeads($))
420 })
421}
422
423// The Context card's body: the switch, the adder, the library, the window.
424// The pane and the band above the prompt both draw it.
425export async function contextBody($: $, ui: Ui, columns: number) {
426 const { Box, Text, Button, Input, Markdown } = ui
427 const rich = isRich(ui)
428 const list = await read($, sources)
429 const groups = await read($, folders)
430 const view = await readView($)
431 const draft = await read($, contextDraft)
432 const status = await read($, contextStatus)
433 const go = (change: Partial<ContextView>) => () => void setView($, change)
434 const isOn = await read($, isContextOn)
435
436 const setting = switchRow(ui, 'context-on', 'Use context', isOn, isOn ? 'Sources that are on go into every request.' : 'No source reaches the agent.', turned => void setContextOn($, turned))
437 if (!isOn) return setting
438
439 const sharedList = list.filter(one => one.mode !== 'off')
440 const sharedTokens = approxTokens(sharedList.reduce((sum, one) => sum + one.text.length, 0))
441 const isOver = sharedTokens > TOKEN_BUDGET
442 const loose = list.filter(one => one.folderId === null)
443 const isThin = (source: Source) => source.kind === 'link' && source.text.length < 200
444 // What a row's title may take: the card less its switch, mark and Strict.
445 const room = Math.max(12, columns - 24)
446
447 const onOff = (key: string, state: 'on' | 'off' | 'some', onPress: () => void) =>
448 drawnButton(ui, key, switchArt(state), state === 'on' ? 'On' : state === 'some' ? 'Some on' : 'Off', 36, 22, onPress) ?? (
449 <Button key={key} plain onPress={onPress}>
450 {state === 'on' ? <Text color="success">●</Text> : state === 'some' ? <Text color="warning">◐</Text> : <Text dimColor>○</Text>}
451 </Button>
452 )
453
454 const sourceRow = (source: Source, isNested: boolean) => {
455 const isSourceOn = source.mode !== 'off'
456 const isOpen = view.openId === source.id
457 return (
458 <Box key={`row-${source.id}`} flexDirection="column" paddingLeft={isNested ? 2 : 0}>
459 <Box justifyContent="space-between" alignItems="center" gap={1}>
460 <Box gap={1} alignItems="center" flexShrink={1}>
461 {onOff(`toggle-${source.id}`, isSourceOn ? 'on' : 'off', () => void setMode($, source.id, isSourceOn ? 'off' : 'guidance'))}
462 {rich ? svg(ui, glyph(source.kind), source.kind, 18, 18) : <Text color={GLYPH_COLOR[source.kind]}>{KIND_MARK[source.kind]}</Text>}
463 <Button key={`open-${source.id}`} plain onPress={go({ openId: isOpen ? null : source.id, isEditingText: false })}>
464 <Text dimColor={!isSourceOn}>{shorten(source.title, room - (isNested ? 2 : 0))}</Text>
465 </Button>
466 </Box>
467 {isSourceOn && (
468 <Button
469 key={`strict-${source.id}`}
470 label="Strict"
471 variant={source.mode === 'strict' ? 'primary' : undefined}
472 dimColor={source.mode !== 'strict'}
473 onPress={() => void setMode($, source.id, source.mode === 'strict' ? 'guidance' : 'strict')}
474 />
475 )}
476 </Box>
477 {isOpen && (
478 <Box flexDirection="column" gap={rich ? 1 : 0} paddingLeft={2} marginTop={rich ? 1 : 0}>
479 <Text dimColor wrap="truncate-end">
480 {facts(source)}
481 {isThin(source) ? ' · may be empty' : ''}
482 </Text>
483 {source.kind === 'file' && /\.mdx?$/i.test(source.path ?? '') ? (
484 <Markdown key={`text-${source.id}`} text={source.text.slice(0, 600)} />
485 ) : (
486 <Text wrap="wrap">{source.text.slice(0, 600) || 'No readable text.'}</Text>
487 )}
488 {source.text.length > 600 && <Text dimColor>… {chars(source.text.length - 600)} more reach the agent</Text>}
489 {view.isEditingText && source.kind === 'note' && Input !== undefined && (
490 <Input key={`edit-text-${source.id}`} label="Text " value={source.text} submitLabel="save" autoFocus onSubmit={value => void editNote($, source.id, value)} />
491 )}
492 {Input !== undefined && <Input key={`rename-source-${source.id}`} label="Title " value={source.title} submitLabel="rename" onSubmit={value => void renameSource($, source.id, value)} />}
493 <Box gap={1} flexWrap="wrap">
494 {source.kind === 'note' && Input !== undefined && (
495 <Button key={`edit-text-start-${source.id}`} label={view.isEditingText ? 'Done' : 'Edit text'} dimColor onPress={go({ isEditingText: !view.isEditingText })} />
496 )}
497 {source.kind !== 'note' && <Button key={`refresh-${source.id}`} label="Refresh" dimColor onPress={() => void refresh($, source)} />}
498 <Button key={`remove-${source.id}`} onPress={() => void removeSource($, source.id)}>
499 <Text color="error">Delete</Text>
500 </Button>
501 </Box>
502 </Box>
503 )}
504 </Box>
505 )
506 }
507
508 const folderBlock = (folder: Folder) => {
509 const members = list.filter(one => one.folderId === folder.id)
510 const onCount = members.filter(one => one.mode !== 'off').length
511 const state = members.length === 0 || onCount === 0 ? 'off' : onCount === members.length ? 'on' : 'some'
512 const isOpen = view.openFolders.includes(folder.id)
513 return (
514 <Box key={`folder-card-${folder.id}`} flexDirection="column">
515 <Box justifyContent="space-between" alignItems="center" gap={1}>
516 <Box gap={1} alignItems="center" flexShrink={1}>
517 {members.length > 0 && onOff(`folder-toggle-${folder.id}`, state, () => void setFolderOn($, folder.id, state !== 'on'))}
518 {rich ? svg(ui, glyph('folder'), 'folder', 18, 18) : <Text color="warning">▣</Text>}
519 <Button key={`fold-${folder.id}`} plain onPress={() => void toggleFolderOpen($, folder.id)}>
520 <Text bold>{shorten(folder.name, room - 8)}</Text>
521 <Text dimColor> {members.length === 0 ? 'empty' : `${onCount}/${members.length}`} {isOpen ? '⌄' : '›'}</Text>
522 </Button>
523 </Box>
524 <Button key={`folder-delete-${folder.id}`} label="Remove" dimColor onPress={go({ deletingFolder: folder.id })} />
525 </Box>
526 {view.deletingFolder === folder.id && (
527 <Box flexDirection="column">
528 <Text dimColor>Remove {folder.name} and its {members.length} sources? The files stay on disk.</Text>
529 <Box gap={1} justifyContent="flex-end">
530 <Button key="delete-cancel" label="Cancel" dimColor onPress={go({ deletingFolder: null })} />
531 <Button key="delete-folder" label="Remove" variant="primary" onPress={() => void deleteFolder($, folder.id)} />
532 </Box>
533 </Box>
534 )}
535 {isOpen && members.map(source => sourceRow(source, true))}
536 </Box>
537 )
538 }
539
540 const isWarning = /^(Could|That link|No text|Skipped)/.test(status)
541 const adder = (
542 <Box key="adder" flexDirection="column" gap={rich ? 1 : 0}>
543 <Box gap={1} alignItems="center" flexWrap="wrap">
544 <Button key="pick-files" label={view.picking === 'file' ? 'Choosing…' : 'Add files…'} dimColor={view.picking !== null} onPress={() => void choose($, 'file')} />
545 <Button key="pick-folder" label={view.picking === 'folder' ? 'Choosing…' : 'Add folder…'} dimColor={view.picking !== null} onPress={() => void choose($, 'folder')} />
546 </Box>
547 {Input !== undefined && (
548 <Input
549 key="add-input"
550 placeholder="Paste a link or write a note"
551 value={draft.input}
552 submitLabel="add"
553 onInput={value => void update($, contextDraft, now => ({ ...now, input: value }))}
554 onSubmit={value => void addFromInput($, value)}
555 />
556 )}
557 {status !== '' && (
558 <Text color={isWarning ? 'warning' : undefined} dimColor={!isWarning} wrap="truncate-end">
559 {status}
560 </Text>
561 )}
562 </Box>
563 )
564
565 const isEmpty = list.length === 0 && groups.length === 0
566 return (
567 <Box flexDirection="column" gap={1}>
568 {setting}
569 {adder}
570 {isEmpty ? (
571 <Text dimColor>No sources yet. Try a note like "Amounts are integer cents".</Text>
572 ) : (
573 <Box flexDirection="column">
574 <Text color={isOver ? 'error' : undefined} dimColor={!isOver}>
575 {sharedList.length} of {list.length} on · about {tokens(sharedTokens)} tokens{isOver ? ', over budget' : ''}
576 </Text>
577 {groups.map(folderBlock)}
578 {loose.map(source => sourceRow(source, false))}
579 </Box>
580 )}
581 {!isEmpty && (await drawWindow($, ui))}
582 </Box>
583 )
584}
585
586
587const KIND_MARK: Record<Source['kind'], string> = { note: '¶', link: '↗', file: '▢' }
588
589// The live context window, one line at the foot of the card.
590async function drawWindow($: $, ui: Ui) {
591 const { Box, Text, Button } = ui
592 // Reading isTurnRunning redraws the card as each turn starts and ends, so
593 // the window figures below are read fresh then.
594 const isRunning = await read($, isTurnRunning)
595 const isShown = await read($, isWindowShown)
596 const usage = await $.session.usage({ breakdown: 'summary' })
597 const breakdown = usage.context.breakdown
598 const used = breakdown?.totalTokens ?? usage.context.tokens ?? 0
599 const window = breakdown?.rawMaxTokens ?? usage.context.window
600 const percent = breakdown?.percentage ?? usage.context.percent ?? 0
601 const categories = (breakdown?.categories ?? []).filter(one => one.kind === 'used' && one.tokens > 0)
602 return (
603 <Box key="window" flexDirection="column">
604 <Box justifyContent="space-between" alignItems="center" gap={1}>
605 <Text wrap="truncate-end">
606 <Text dimColor>Window </Text>
607 <Text color={fillColor(percent)} bold>
608 {percent}%
609 </Text>
610 <Text dimColor>
611 {' '}
612 · {tokens(used)} of {tokens(window)}
613 </Text>
614 </Text>
615 <Box gap={1} flexShrink={0}>
616 <Button key="window-details" label={isShown ? 'Hide' : 'Details'} dimColor onPress={() => void update($, isWindowShown, now => !now)} />
617 {!isRunning && <Button key="usage-compact" label="Compact" dimColor onPress={() => void $.session.compact({})} />}
618 </Box>
619 </Box>
620 {isShown &&
621 categories.map(category => (
622 <Text>
623 <Text dimColor>{category.name.slice(0, 18).padEnd(19)}</Text>
624 {tokens(category.tokens).padStart(6)} <Text color="promptBorder">{bar(category.tokens / Math.max(1, used), 10)}</Text>
625 </Text>
626 ))}
627 </Box>
628 )
629}
630hooks/dials.tsx 421 lines1// Six of CommissionAI's crew dials (protocol/fixtures/dials.json), with the
2// per-level prompts reworded for a Claude Code session: no task_blocked or
3// task_complete tools, no board check command, no $COMMISSION_DEV_PORT. Some
4// levels borrow practices from pStack (Lauren Tan, MIT): verdicts, a failing
5// test before a fix, done-predicates for subagents, testing a shared premise.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, On } from 'claude-code'
8
9import type { DialKey, DialValues, Tab } from '../types'
10import { presetArt, switchArt } from './art'
11import { enforcedRule } from './enforce'
12import { chip, drawnButton, isRich, ON_OFF, segmented, svg, switchRow } from './kit'
13import { setSetting } from './settings'
14import { BALANCED, paneHeads, presetName, PRESETS } from './state'
15import { bar, frame, PANE, resolveUi } from './ui'
16import type { Ui } from './ui'
17
18export { BALANCED, presetName }
19
20const dials = atom({ plugin: 'fishstack', key: 'dials' } as const, BALANCED)
21const isBriefShown = atom({ plugin: 'fishstack', key: 'isBriefShown' } as const, false)
22const selectedDial = atom({ plugin: 'fishstack', key: 'selectedDial' } as const, null)
23const tab = atom({ plugin: 'fishstack', key: 'tab' } as const, null)
24const isClientDown = atom({ plugin: 'fishstack', key: 'isClientDown' } as const, false)
25const isDialsOn = atom({ plugin: 'fishstack', key: 'isDialsOn' } as const, true)
26
27// The Use dials switch, kept across sessions for each repository. Off, the agent gets no working
28// style and the dials do nothing; their settings are kept for when it is on.
29export const DIALS_ON_KEY = 'useDials'
30
31async function setDialsOn($: EngineInterface, isOn: boolean): Promise<void> {
32 await setSetting($, DIALS_ON_KEY, isOn)
33 await update($, isDialsOn, () => isOn)
34}
35
36// The Enforce switch, kept across sessions for each repository: on, the rules enforce.ts can
37// check hold the agent to some levels in code; off, the dials are the
38// prompt alone.
39const isEnforced = atom({ plugin: 'fishstack', key: 'isEnforced' } as const, true)
40export const ENFORCE_KEY = 'enforceDials'
41
42async function setEnforced($: EngineInterface, isOn: boolean): Promise<void> {
43 await setSetting($, ENFORCE_KEY, isOn)
44 await update($, isEnforced, () => isOn)
45}
46
47type Level = { label: string; summary: string; prompt: string }
48type Dial = { key: DialKey; name: string; question: string; levels: Level[] }
49
50const DIALS: Dial[] = [
51 {
52 key: 'foresight',
53 name: 'Foresight',
54 question: 'How much it plans before editing',
55 levels: [
56 { label: 'Dive in', summary: 'Starts editing right away.', prompt: 'Start editing right away. Do not write a plan or spec first.' },
57 { label: 'Sketch', summary: 'States a two-line approach first.', prompt: 'Before editing, state your approach in two or three lines.' },
58 { label: 'Plan steps', summary: 'Lists the steps and files before editing.', prompt: 'Before changing any file, post a message listing the steps you will take and the files you expect to touch.' },
59 { label: 'Write a spec', summary: 'Writes a short spec with acceptance criteria before any code.', prompt: 'Before changing any file, post a short spec as a message: the problem, your approach, the files involved and the acceptance criteria. Then implement it.' },
60 { label: 'Deep spec', summary: 'Researches, brainstorms options, grills its own plan and writes a full spec before any code.', prompt: 'Research the relevant code and brainstorm at least three approaches. Before changing any file, post two messages: your self-grilling, answering what a skeptical reviewer would reject, which inputs break the plan, what else could break, what the smallest version is and what would prove it works; then a full spec with the headings Problem, Constraints, Approach, Alternatives considered, Edge cases, Risks, Files, Acceptance criteria.' },
61 ],
62 },
63 {
64 key: 'crew',
65 name: 'Crew',
66 question: 'How readily it starts subagents',
67 levels: [
68 { label: 'Solo', summary: 'Works alone and never starts subagents.', prompt: 'Do all the work yourself. Do not start subagents or parallel tasks.' },
69 { label: 'Scouts', summary: 'Sends one scout to find the code, then works alone.', prompt: 'Before editing, start one subagent as a scout to find the code that matters (file paths and short findings, not code). Make every edit yourself and delegate nothing else. Give the scout a fast, cheaper model if the Agent tool lets you pick one: it only reads.' },
70 { label: 'Helpers', summary: 'A scout up front. You keep the implementation.', prompt: 'Start one scout subagent to find the relevant code. Keep the core implementation yourself. On design work (UI or visual design, architecture, schema or API shape), also start one fresh subagent to critique your design in writing (what breaks, with evidence, and a simpler option) before you build.' },
71 { label: 'Squads', summary: 'One parallel subagent per independent part.', prompt: 'Do not write the parts yourself. Split the implementation into independent parts and start one subagent per part with the Agent tool, all in one message so they run in parallel, even when a part is small. First state what done means. Give each part its own files: parts that share a file run one after another. Each subagent ends with PASS, ISSUES or BLOCKED and its evidence; a part with no such report is a gap, not a pass. Then integrate the results yourself. On design work, first convene a council of two subagents that each propose a design independently and then give each other written feedback, and merge the best of both before you build.' },
72 { label: 'Swarm', summary: 'Fans out to dozens of fast, cheap subagents, as many as the work parallelizes.', prompt: 'Do not investigate or write the parts yourself. Start one investigator subagent per area first, then one subagent per independent part, all in parallel; use at least two subagents even on a small task. Say first whether they split the work or race at the same problem. On a larger one swarm: one subagent per file, call site, test or question that can run independently, as many as the work has parts, on the fastest cheapest model you have, until more subagents would only add coordination. You only integrate the results. On design work, convene a council of three subagents proposing from different angles, have one more subagent write feedback on each and recommend a base, then merge and have it check the merge before you build.' },
73 ],
74 },
75 {
76 key: 'tenacity',
77 name: 'Tenacity',
78 question: 'How hard it pushes to finish',
79 levels: [
80 { label: 'One pass', summary: 'One attempt, then reports back. No retries.', prompt: 'Make one focused attempt. If you hit a blocker, stop and end your turn saying what blocked you instead of working around it.' },
81 { label: 'Retry once', summary: 'Tries one alternative before stopping.', prompt: 'If an approach fails, try one alternative before stopping to report the blocker.' },
82 { label: 'Push through', summary: 'Works around blockers it can solve itself.', prompt: 'Work around obstacles you can solve yourself before stopping to report a blocker. After two failed fixes that rest on the same assumption, write the assumption down and test it before a third.' },
83 { label: 'Checklist', summary: 'Keeps a checklist and keeps going until every item is done.', prompt: 'Before editing, post a message headed Checklist with one line per thing the request requires, tests and cleanup included. Keep working until every line is done, then list each line with its status under a Checklist heading in your final message. Only stop early for things you cannot resolve yourself.' },
84 { label: 'Goal mode', summary: 'Sets acceptance criteria and keeps going until each one is verified.', prompt: 'Treat this request as a goal. Before editing, write explicit, testable acceptance criteria as a numbered checklist in a message headed Acceptance criteria, each naming the command or check that will verify it. Keep working until every criterion is verified with evidence you produced, never weakening a criterion to call it met (a plateau is not a stop), and list each one with its evidence under an Acceptance criteria heading in your final message. Only stop early for things that need the user.' },
85 ],
86 },
87 {
88 key: 'autonomy',
89 name: 'Autonomy',
90 question: 'How often it asks you',
91 levels: [
92 { label: 'Ask first', summary: 'Stops to ask about any decision the request leaves open before editing.', prompt: 'Before editing, look for decisions the request leaves open, such as behavior, naming, scope or dependencies. If there are any, ask with AskUserQuestion right away, giving the options and your recommendation, and do not change files until the user answers.' },
93 { label: 'Check in', summary: 'Asks about product and scope decisions.', prompt: 'If the request leaves a product, scope or public API decision open, ask with AskUserQuestion, giving the options and your recommendation, before building that part. Decide implementation details yourself.' },
94 { label: 'When unsure', summary: 'Asks only when it is genuinely unsure.', prompt: 'Decide routine questions yourself. Ask with AskUserQuestion when the answer is a business fact you cannot find in the code or project context (a rate, price, policy, deadline or approver), or when a wrong guess would be costly to undo.' },
95 { label: 'Decide, note', summary: 'Makes the call and records it in the summary.', prompt: 'Make judgment calls yourself and list each one with its reasoning in your final message. Ask only when the work cannot continue without the user. If running something could answer a question, run it instead of asking. You may act outside the repository when it is reversible, but ask before messaging people.' },
96 { label: 'Autopilot', summary: 'Works as if the user is away: never asks, clears its own blockers, never deletes data.', prompt: 'Assume the user is away and will never answer: do not ask, decide every open question, clear your own blockers, and act outside the repository whenever it helps (other apps, connected tools, services). Message people only when the work cannot move without them, such as a review approval. Never delete data. Never force-push or deploy to production without the user. Record each decision in your final message. If you must ask with AskUserQuestion, put your recommended option first and end its label with (Recommended): when nobody answers within 15 minutes it is chosen for you.' },
97 ],
98 },
99 {
100 key: 'proof',
101 name: 'Proof',
102 question: 'How much it verifies its work',
103 levels: [
104 { label: 'Trust', summary: 'No new tests or checks.', prompt: 'Do not add tests, run extra checks or open the app to check your work.' },
105 { label: 'Compile', summary: 'Makes sure it builds and typechecks; no new tests.', prompt: 'Make sure the project builds and typechecks. Do not add tests unless the request asks for them, and do not open the app for QA.' },
106 { label: 'Run tests', summary: 'Runs the relevant tests; adds a lean test only for risky new logic.', prompt: 'Run the tests relevant to your change and fix what breaks. Add a unit test only for new logic with real risk, and exercise the change once for real. Report each check as VERIFIED, NOT VERIFIED or INCONCLUSIVE: inconclusive is not a pass. For a bug, show the test failing before the fix and passing after.' },
107 { label: 'Component QA', summary: 'Lean tests plus real calls to what changed.', prompt: 'Keep unit tests lean. For every function, component or endpoint you change, make real calls (a success case, an invalid input and an edge case; for a library or CLI, run it through its public entry point) and list each call and its result in your final message.' },
108 { label: 'Full QA', summary: 'Lean tests plus end-to-end QA of the running app, with evidence.', prompt: 'Keep unit tests lean. QA the product end to end like a tester: with a UI, start the dev server and walk every journey in a browser; without one, run the real entry points through at least three journeys (happy path, edge case, failure), then repeat every journey after your last edit. List each journey, its input and what you saw in your final message.' },
109 ],
110 },
111 {
112 key: 'voice',
113 name: 'Voice',
114 question: 'How much it talks',
115 levels: [
116 { label: 'Silent', summary: 'Reports only the outcome.', prompt: 'Keep messages minimal: no progress narration; report only the outcome in your final message.' },
117 { label: 'Terse', summary: 'Short updates with no preamble.', prompt: 'Be terse: short status lines, no preamble, no restating the task. Terse is no excuse to drop content: every claim carries its evidence.' },
118 { label: 'Normal', summary: 'Brief progress notes and a clear summary.', prompt: 'Give brief progress notes at milestones and a clear summary at the end.' },
119 { label: 'Explain', summary: 'Explains decisions and trade-offs as it goes.', prompt: 'Before each significant change, post two full sentences explaining the decision and its trade-off. End with a final message with four labelled parts: What changed, Why this way, What I verified, What I left out. Write in full plain sentences, whatever other style instructions say.' },
120 { label: 'Teach', summary: 'Explains every step in full sentences and ends with a walkthrough of alternatives and lessons.', prompt: 'After each step, post a message of three or more full sentences explaining what you found and why you are taking the next step. End with a final walkthrough with five labelled parts: How it worked before, What I changed and why, Alternatives I considered, What I learned about this codebase, What to know next time. Write in full plain sentences, whatever other style instructions say.' },
121 ],
122 },
123]
124
125export const DIALS_STORE_KEY = 'dials'
126export const DIALS_SECTION_ID = 'fishstack:dials'
127
128function levelOf(dial: Dial, values: DialValues): Level {
129 return dial.levels[values[dial.key] - 1]!
130}
131
132export function brief(values: DialValues): string {
133 const lines = DIALS.map(dial => {
134 const level = levelOf(dial, values)
135 return `- ${dial.name} (${level.label}): ${level.prompt}`
136 })
137 return ['## Working style', 'The user set these dials for how you work in this session. Follow them.', ...lines].join('\n')
138}
139
140export function isDialValues(value: unknown): value is DialValues {
141 if (value === null || typeof value !== 'object') return false
142 const record = value as Record<string, unknown>
143 return DIALS.every(dial => {
144 const level = record[dial.key]
145 return typeof level === 'number' && Number.isInteger(level) && level >= 1 && level <= 5
146 })
147}
148
149async function setDials($: EngineInterface, change: (values: DialValues) => DialValues): Promise<void> {
150 await update($, dials, change)
151 await setSetting($, DIALS_STORE_KEY, await read($, dials))
152}
153
154// CommissionAI's dial hues (Radar.tsx HUES), as hex an SVG image draws on any
155// theme, and its spend and pace weights for the six dials kept here.
156const COLOR: Record<DialKey, string> = {
157 foresight: '#8f9cff',
158 crew: '#5aaeff',
159 tenacity: '#22c4dc',
160 autonomy: '#2fcfa6',
161 proof: '#74d16f',
162 voice: '#d8bf45',
163}
164
165const SPEND: Record<DialKey, number[]> = {
166 foresight: [0.9, 0.95, 1, 1.08, 1.15],
167 crew: [0.9, 0.95, 1, 1.3, 1.7],
168 tenacity: [0.7, 0.85, 1, 1.25, 1.6],
169 autonomy: [1, 1, 1, 1, 1],
170 proof: [0.9, 0.95, 1, 1.1, 1.25],
171 voice: [0.95, 0.97, 1, 1.05, 1.1],
172}
173
174const PACE: Record<DialKey, number[]> = {
175 foresight: [0.9, 0.95, 1, 1.1, 1.2],
176 crew: [1.05, 1.02, 1, 0.9, 0.8],
177 tenacity: [0.7, 0.85, 1, 1.2, 1.5],
178 autonomy: [1.4, 1.2, 1, 0.95, 0.9],
179 proof: [0.9, 0.95, 1, 1.15, 1.3],
180 voice: [1, 1, 1, 1.03, 1.06],
181}
182
183const ASKS = ['Often', 'On big calls', 'When unsure', 'Rarely', 'Almost never']
184
185export function estimate(values: DialValues): { spend: number; pace: number; paceLabel: string; asks: string } {
186 const spend = Math.max(0.4, 1 + 0.5 * DIALS.reduce((total, dial) => total + SPEND[dial.key][values[dial.key] - 1]! - 1, 0))
187 const pace = DIALS.reduce((total, dial) => total * PACE[dial.key][values[dial.key] - 1]!, 1)
188 const paceLabel = pace < 0.8 ? 'Quick' : pace < 1.15 ? 'Normal' : pace < 1.6 ? 'Longer' : 'Long haul'
189 return { spend, pace, paceLabel, asks: ASKS[values.autonomy - 1]! }
190}
191
192function spendText(spend: number): string {
193 const percent = Math.round((spend - 1) * 100)
194 return percent === 0 ? 'Baseline' : `${percent > 0 ? '+' : '−'}${Math.abs(percent)}%`
195}
196
197const RADAR_W = 360
198const RADAR_H = 300
199const RADIUS = 104
200
201function point(index: number, level: number): [number, number] {
202 const angle = ((-90 + (index * 360) / DIALS.length) * Math.PI) / 180
203 const reach = RADIUS * (level / 5)
204 return [RADAR_W / 2 + Math.cos(angle) * reach, RADAR_H / 2 + Math.sin(angle) * reach]
205}
206
207function polygon(levels: number[]): string {
208 return levels.map((level, index) => point(index, level).map(n => n.toFixed(1)).join(',')).join(' ')
209}
210
211// The radar of CommissionAI's Dials page: rings per level, the dial shape
212// filled with the dials' hues, a dot per dial and its name and level.
213export function radar(values: DialValues, selected: DialKey | null): string {
214 const levels = DIALS.map(dial => values[dial.key])
215 const rings = [1, 2, 3, 4, 5]
216 .map(ring => `<polygon points="${polygon(DIALS.map(() => ring))}" fill="none" stroke="#8b949e" stroke-opacity="${ring === 5 ? 0.45 : 0.2}" stroke-dasharray="${ring === 5 ? '' : '2 3'}"/>`)
217 .join('')
218 const spokes = DIALS.map((_, index) => {
219 const [x, y] = point(index, 5)
220 return `<line x1="${RADAR_W / 2}" y1="${RADAR_H / 2}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="#8b949e" stroke-opacity="0.22"/>`
221 }).join('')
222 const stops = DIALS.map((dial, index) => `<stop offset="${((index / (DIALS.length - 1)) * 100).toFixed(0)}%" stop-color="${COLOR[dial.key]}"/>`).join('')
223 const edges = DIALS.map((dial, index) => {
224 const [x1, y1] = point(index, levels[index]!)
225 const [x2, y2] = point((index + 1) % DIALS.length, levels[(index + 1) % DIALS.length]!)
226 return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${COLOR[dial.key]}" stroke-width="2.4" stroke-linecap="round"/>`
227 }).join('')
228 const dots = DIALS.map((dial, index) => {
229 const [x, y] = point(index, levels[index]!)
230 const isSelected = dial.key === selected
231 const halo = isSelected ? `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="11" fill="${COLOR[dial.key]}" fill-opacity="0.25"/>` : ''
232 return `${halo}<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${isSelected ? 7 : 5.5}" fill="${COLOR[dial.key]}" stroke="#1b1d22" stroke-width="2"/>`
233 }).join('')
234 const labels = DIALS.map((dial, index) => {
235 const [x, y] = point(index, 6.3)
236 const anchor = Math.abs(x - RADAR_W / 2) < 4 ? 'middle' : x < RADAR_W / 2 ? 'end' : 'start'
237 const isSelected = dial.key === selected
238 return `<text x="${x.toFixed(1)}" y="${(y + 4).toFixed(1)}" text-anchor="${anchor}" font-family="-apple-system, system-ui, sans-serif" font-size="13" font-weight="${isSelected ? 600 : 400}" fill="${isSelected ? COLOR[dial.key] : '#9aa1ab'}">${dial.name} <tspan fill="${isSelected ? COLOR[dial.key] : '#6e7681'}">${levels[index]}</tspan></text>`
239 }).join('')
240 return `<svg xmlns="http://www.w3.org/2000/svg" width="${RADAR_W}" height="${RADAR_H}" viewBox="0 0 ${RADAR_W} ${RADAR_H}"><defs><linearGradient id="fill" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient><radialGradient id="glow"><stop offset="0%" stop-color="#8f9cff" stop-opacity="0.10"/><stop offset="100%" stop-color="#8f9cff" stop-opacity="0"/></radialGradient></defs><circle cx="${RADAR_W / 2}" cy="${RADAR_H / 2}" r="${RADIUS * 1.25}" fill="url(#glow)"/>${rings}${spokes}<polygon points="${polygon(levels)}" fill="url(#fill)" fill-opacity="0.28"/>${edges}${dots}${labels}</svg>`
241}
242
243function pips(level: number, color: string): string {
244 const cells = [1, 2, 3, 4, 5]
245 .map(index => `<rect x="${(index - 1) * 22}" y="0" width="18" height="18" rx="5" fill="${index <= level ? color : '#8b949e'}" fill-opacity="${index <= level ? 1 : 0.22}"/>`)
246 .join('')
247 return `<svg xmlns="http://www.w3.org/2000/svg" width="106" height="18" viewBox="0 0 106 18">${cells}</svg>`
248}
249
250
251// What the radar's Client posts: a dial and its level, and whether the press
252// selects that dial. Posted by code, so checked before it moves anything.
253function isRadarPost(data: unknown): data is { dial: number; level: number; select?: number } {
254 const post = data as { dial?: unknown; level?: unknown } | null
255 return (
256 post !== null &&
257 typeof post === 'object' &&
258 Number.isInteger(post.dial) &&
259 Number.isInteger(post.level) &&
260 (post.dial as number) >= 0 &&
261 (post.dial as number) < DIALS.length &&
262 (post.level as number) >= 1 &&
263 (post.level as number) <= 5
264 )
265}
266
267export function registerDials(on: On): void {
268 on('ui.message', { element: 'radar-drag' }, async ($, e, next) => {
269 if (!isRadarPost(e.data)) return next(e)
270 const dial = DIALS[e.data.dial]!
271 const level = e.data.level
272 if (e.data.select !== undefined) await update($, selectedDial, () => dial.key)
273 await setDials($, now => ({ ...now, [dial.key]: level }))
274 return next(e)
275 })
276
277 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
278 if ((await read($, tab)) !== 'dials') return next(e)
279 const ui: Ui = resolveUi($.ui.resolve(e), e.surface, await read($, isClientDown))
280 return frame(ui, 'dials', e.props.bodyColumns, id => void update($, tab, () => id), await dialsBody($, ui, e.props.bodyColumns), await paneHeads($))
281 })
282}
283
284// The Dials card's body: the switch, the presets, a row a dial, Enforce.
285// The pane and the band above the prompt both draw it; the radar only where
286// it is wide.
287export async function dialsBody($: EngineInterface, ui: Ui, columns: number) {
288 const { Box, Text, Button, Markdown } = ui
289 const { Client } = ui
290 const rich = isRich(ui)
291 const values = await read($, dials)
292 const selected = await read($, selectedDial)
293 const isShown = await read($, isBriefShown)
294 const current = presetName(values)
295 // The radar only where the pane is wide; a sidebar keeps the rows.
296 const isWide = columns >= 86
297 const guess = estimate(values)
298 const pick = (key: DialKey, level: number) => void setDials($, now => ({ ...now, [key]: Math.min(5, Math.max(1, level)) }))
299 const isOn = await read($, isDialsOn)
300 const isEnforcing = await read($, isEnforced)
301
302 const setting = switchRow(ui, 'dials-on', 'Use dials', isOn, isOn ? 'The agent works the way these dials say.' : 'The agent gets no working style.', turned => void setDialsOn($, turned))
303 if (!isOn) return setting
304
305 const presets = (
306 <Box key="presets" gap={1} flexWrap="wrap" alignItems="center">
307 {PRESETS.map(preset => {
308 const isActive = preset.name === current
309 const art = presetArt(preset.name, DIALS.map(dial => preset.values[dial.key]), isActive)
310 return (
311 drawnButton(ui, `preset-${preset.name}`, art.source, preset.name, art.width, art.height, () => void setDials($, () => preset.values)) ?? (
312 <Button key={`preset-${preset.name}`} label={preset.name} variant={isActive ? 'primary' : undefined} dimColor={!isActive} onPress={() => void setDials($, () => preset.values)} />
313 )
314 )
315 })}
316 {current === 'Custom' && chip(ui, 'Custom', 'claude')}
317 </Box>
318 )
319
320 const radarBox = isWide && rich && (
321 <Box key="radar-box" justifyContent="center">
322 <Box key="radar" position="relative">
323 {svg(ui, radar(values, selected), `Dials: ${summaryOf(values)}. Drag a dot to change its level.`, RADAR_W, RADAR_H)}
324 {Client !== undefined && (
325 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
326 <Client key="radar-drag" module="./radar-drag.tsx" props={{ levels: DIALS.map(dial => values[dial.key]), width: RADAR_W, height: RADAR_H, radius: RADIUS }} width="100%" height="100%" />
327 </Box>
328 )}
329 </Box>
330 </Box>
331 )
332
333 // One row a dial: its name opens what it does and what the agent is told.
334 const dialRow = (dial: Dial) => {
335 const level = levelOf(dial, values)
336 const value = values[dial.key]
337 const isSelected = selected === dial.key
338 const color = COLOR[dial.key]
339 const rule = isEnforcing ? enforcedRule(dial.key, value) : null
340 return (
341 <Box key={`card-${dial.key}`} flexDirection="column">
342 <Box justifyContent="space-between" alignItems="center" gap={1}>
343 <Box gap={1} alignItems="center" flexShrink={1}>
344 <Button key={`dial-${dial.key}`} plain onPress={() => void update($, selectedDial, now => (now === dial.key ? null : dial.key))}>
345 <Text bold color={isSelected ? color : undefined}>
346 {dial.name.padEnd(10)}
347 </Text>
348 </Button>
349 {rich ? (
350 svg(ui, pips(value, color), `${dial.name} ${value} of 5`, 106, 18)
351 ) : (
352 <Box>
353 {dial.levels.map((_, index) => (
354 <Button key={`pip-${dial.key}-${index + 1}`} plain onPress={() => pick(dial.key, index + 1)}>
355 {index < value ? <Text color={color}>■</Text> : <Text dimColor>□</Text>}
356 </Button>
357 ))}
358 </Box>
359 )}
360 </Box>
361 {rich ? (
362 <Box gap={1} alignItems="center" flexShrink={0}>
363 <Button key={`less-${dial.key}`} label="−" dimColor onPress={() => pick(dial.key, value - 1)} />
364 <Button key={`more-${dial.key}`} label="+" dimColor onPress={() => pick(dial.key, value + 1)} />
365 </Box>
366 ) : (
367 <Text color={color} wrap="truncate-end">
368 {level.label}
369 </Text>
370 )}
371 </Box>
372 {rich && (
373 <Text color={color} wrap="truncate-end">
374 {level.label}
375 </Text>
376 )}
377 {isSelected && (
378 <Box flexDirection="column" paddingLeft={1}>
379 <Text wrap="wrap" dimColor>
380 {dial.question}: {level.summary}
381 </Text>
382 {rule !== null && (
383 <Text wrap="wrap" dimColor>
384 <Text color={color}>Enforced:</Text> {rule}
385 </Text>
386 )}
387 <Text wrap="wrap">{level.prompt}</Text>
388 </Box>
389 )}
390 </Box>
391 )
392 }
393
394 return (
395 <Box flexDirection="column" gap={1}>
396 {setting}
397 {presets}
398 {radarBox}
399 <Text dimColor wrap="wrap">
400 {spendText(guess.spend)} spend · {guess.paceLabel.toLowerCase()} · asks {guess.asks.toLowerCase()}
401 </Text>
402 <Box flexDirection="column" gap={rich ? 1 : 0}>
403 {DIALS.map(dialRow)}
404 </Box>
405 <Box key="dials-more" justifyContent="space-between" alignItems="center" gap={1} flexWrap="wrap">
406 <Box gap={1} alignItems="center">
407 <Text dimColor>Enforce</Text>
408 {drawnButton(ui, 'dials-enforce', switchArt(isEnforcing ? 'on' : 'off'), `Enforce: ${isEnforcing ? 'on' : 'off'}`, 36, 22, () => void setEnforced($, !isEnforcing)) ??
409 segmented(ui, 'dials-enforce', ON_OFF, isEnforcing ? 'on' : 'off', choice => void setEnforced($, choice === 'on'))}
410 </Box>
411 <Button key="brief-toggle" label={isShown ? 'Hide brief' : 'What agents are told'} hotkey="w" dimColor onPress={() => void update($, isBriefShown, now => !now)} />
412 </Box>
413 {isShown && <Markdown text={brief(values)} />}
414 </Box>
415 )
416}
417
418function summaryOf(values: DialValues): string {
419 return DIALS.map(dial => `${dial.name} ${values[dial.key]}`).join(', ')
420}
421hooks/guard.ts 130 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { checkedOutBranches, isReadOnlyShell } from './readonly'
5import { BALANCED } from './dials'
6import { crewRefusal, EDIT_TOOLS, foresightRefusal, isCheckCommand, isShellWrite, noteAgent, noteChange, noteCheck, notePlan, noteSaid, stopReason, SUBAGENT_TOOLS } from './enforce'
7import { defaultBranch, IDLE_WORKTREE, isReadOnlyPhase, READ_ONLY_DENY } from './worktree'
8
9const dials = atom({ plugin: 'fishstack', key: 'dials' } as const, BALANCED)
10const worktree = atom({ plugin: 'fishstack', key: 'worktree' } as const, IDLE_WORKTREE)
11const isDialsOn = atom({ plugin: 'fishstack', key: 'isDialsOn' } as const, true)
12const isEnforced = atom({ plugin: 'fishstack', key: 'isEnforced' } as const, true)
13
14const WRITE_TOOLS = new Set([...EDIT_TOOLS, 'ExitPlanMode'])
15
16type Call = { tool: string; agentId?: string; command?: unknown; file_path?: unknown; notebook_path?: unknown; plan?: unknown }
17
18// Whether the dials are enforced now: on, and their Enforce switch on.
19async function isEnforcing($: EngineInterface): Promise<boolean> {
20 return (await read($, isDialsOn)) && (await read($, isEnforced))
21}
22
23// The dials' refusal of a call before it runs, or null. Any failure here
24// lets the call through: enforcement must never be what stops a session.
25async function refusalOf($: EngineInterface, call: Call): Promise<string | null> {
26 try {
27 // Subagents work as they are told; the dials hold the main agent.
28 if (call.agentId !== undefined || !(await isEnforcing($))) return null
29 const values = await read($, dials)
30 if (SUBAGENT_TOOLS.has(call.tool)) return crewRefusal(values.crew)
31 if (EDIT_TOOLS.has(call.tool)) return foresightRefusal(values.foresight)
32 return null
33 } catch {
34 return null
35 }
36}
37
38// A worktree fishStack set up never takes the default branch: git checks a
39// branch out in one worktree at a time, so a worktree on it locks the main
40// checkout out of its own default branch. Null lets the command run; so does
41// any failure here.
42async function defaultBranchRefusal($: EngineInterface, command: unknown): Promise<string | null> {
43 try {
44 if (typeof command !== 'string') return null
45 const { phase, main, path } = await read($, worktree)
46 if (phase !== 'ready' || main === null || path === null) return null
47 const here = checkedOutBranches(command).filter(one => one.dir === null || one.dir === path || one.dir.startsWith(`${path}/`))
48 if (here.length === 0) return null
49 const branch = await defaultBranch($, main)
50 if (!here.some(one => one.branch === branch)) return null
51 return `fishStack: this worktree does not check out \`${branch}\`. It stays free for the main checkout at \`${main}\`, since git lets only one worktree have a branch at a time. To work from the latest ${branch}, run \`git fetch origin ${branch}\`, then \`git switch -c <new-branch> origin/${branch}\` (or \`git switch --detach origin/${branch}\` to look around).`
52 } catch {
53 return null
54 }
55}
56
57// What a call that ran tells the turn's facts. Never throws.
58function record(call: Call, isError: boolean): void {
59 try {
60 if (call.tool === 'Bash' && typeof call.command === 'string') {
61 // A check counts whether it passed or not: what it found is the
62 // agent's to report.
63 if (isCheckCommand(call.command)) noteCheck()
64 else if (!isError && isShellWrite(call.command)) noteChange(null)
65 return
66 }
67 if (isError) return
68 if (EDIT_TOOLS.has(call.tool)) {
69 const path = typeof call.file_path === 'string' ? call.file_path : typeof call.notebook_path === 'string' ? call.notebook_path : null
70 noteChange(path)
71 } else if (call.agentId === undefined && SUBAGENT_TOOLS.has(call.tool)) noteAgent()
72 else if (call.agentId === undefined && (call.tool === 'TodoWrite' || call.tool === 'ExitPlanMode')) notePlan()
73 } catch {
74 // The facts are a hint; a call is never failed over them.
75 }
76}
77
78export function registerGuard(on: On): void {
79 // What the main agent writes, noted as it streams and passed on untouched:
80 // Foresight's spec, read before the edit that follows it in one response.
81 on('turn.step', async function* ($, e, next) {
82 const stream = next(e)
83 let block: number | null = null
84 for await (const chunk of stream) {
85 if (e.agentId === undefined && chunk.kind === 'text') {
86 // Blocks and steps apart, so a heading starts its own line.
87 if (chunk.index !== block) noteSaid('\n')
88 block = chunk.index
89 noteSaid(chunk.text)
90 }
91 yield chunk
92 }
93 // Returning nothing lets the result beneath stand.
94 })
95
96 on('tool.call', async ($, e, next) => {
97 const tool = String(e.tool)
98 if (isReadOnlyPhase((await read($, worktree)).phase)) {
99 const isWrite = WRITE_TOOLS.has(tool) || (e.tool === 'Bash' && !isReadOnlyShell(e.command))
100 if (isWrite) return { deny: READ_ONLY_DENY }
101 }
102 if (e.tool === 'Bash') {
103 const kept = await defaultBranchRefusal($, e.command)
104 if (kept !== null) return { deny: kept }
105 }
106 const call = { ...(e as unknown as Call), tool }
107 const refusal = await refusalOf($, call)
108 if (refusal !== null) return { deny: refusal }
109 const done = await next(e)
110 record(call, 'deny' in done || (done as { isError?: boolean }).isError === true)
111 return done
112 }).catch(($, e, next) =>
113 next.called ? next(e) : { deny: 'fishStack could not check this call, so it was refused.' },
114 )
115
116 // The end of a turn that changed code, held back once if the dials ask for
117 // something it has not done: never twice (stop_hook_active), never on a
118 // failure here.
119 on('classic.Stop', async ($, e, next) => {
120 const done = await next(e)
121 try {
122 if (done.block !== undefined || !(await isEnforcing($))) return done
123 const reason = stopReason(await read($, dials), e.last_assistant_message ?? '', e.stop_hook_active === true)
124 return reason === null ? done : { ...done, block: reason }
125 } catch {
126 return done
127 }
128 })
129}
130hooks/pane.tsx 91 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On } from 'claude-code'
3
4import type { Tab } from '../types'
5import { hoverHandlers, pressHandlers } from './kit'
6import { paneHeads } from './state'
7import { frame, isTab, PANE, PANE_COLUMNS, PANE_TITLE, resolveUi, TABS } from './ui'
8import type { Ui } from './ui'
9
10const tab = atom({ plugin: 'fishstack', key: 'tab' } as const, null)
11const isClientDown = atom({ plugin: 'fishstack', key: 'isClientDown' } as const, false)
12
13// /fishstack opens a card under the rail, above the prompt, and /fs is its
14// short form: bare /fish reads as the fish shell. /fs panel opens the cards
15// as a pane of their own instead, docked beside the chat where it docks.
16export const FISH_COMMANDS: CommandSpec[] = [
17 { name: 'fishstack', description: 'Open a fishStack card above the prompt: worktree, dials or context', argumentHint: '[worktree|dials|context|panel]' },
18 { name: 'fs', description: 'Open a fishStack card (short for /fishstack)', argumentHint: '[worktree|dials|context|panel]' },
19]
20
21async function openCard($: EngineInterface, args: string): Promise<{ text: string }> {
22 const wanted = args.trim().toLowerCase()
23 if (wanted === 'panel') {
24 await $.ui.open({ id: PANE, title: PANE_TITLE, columns: PANE_COLUMNS, focus: true })
25 return { text: 'Opened the fishStack panel.' }
26 }
27 if (wanted !== '' && !isTab(wanted)) {
28 return { text: `No card named "${wanted}". Try /fs worktree, dials, context or panel.` }
29 }
30 const card: Tab = isTab(wanted) ? wanted : ((await read($, tab)) ?? 'worktree')
31 await update($, tab, () => card)
32 return { text: `Opened fishStack · ${TABS.find(one => one.id === card)!.label}, above the prompt.` }
33}
34
35// Faults heard since this load: a reload starts over, as session.ts does.
36let faults = 0
37
38const FAULT_RETRY_MS = 60_000
39const FAULT_RETRIES = 2
40
41// How long the Buttons stand in after a Client's fault before the drawn
42// controls are tried again, or null to keep them down for the session: a
43// module that never loaded will not load on a retry, and a third fault in
44// one load is no passing hiccup.
45export function faultRetryMs(phase: 'load' | 'render' | 'run', faultsSoFar: number): number | null {
46 return phase === 'load' || faultsSoFar > FAULT_RETRIES ? null : FAULT_RETRY_MS
47}
48
49export function registerPane(on: On): void {
50 // Every other drawn control: its press, or the pointer entering or leaving
51 // it, runs what its drawing registered.
52 on('ui.message', async ($, e, next) => {
53 const data = e.data as { press?: unknown; hover?: unknown } | null
54 if (data?.press === true) pressHandlers.get(e.element)?.()
55 else if (typeof data?.hover === 'boolean') hoverHandlers.get(e.element)?.(data.hover)
56 return next(e)
57 })
58
59 // A Client that failed stays drawn as the desktop's placeholder unless the
60 // redraw the engine makes leaves it out: every drawn control falls back to
61 // its Button at once. A slow or failed render is often the desktop busy for
62 // a moment, so the controls come back after a while and try again; a module
63 // that never loaded, or one that keeps failing, stays down until a load
64 // (session.ts clears it at session.start). Only giving up is said in the
65 // transcript: a fallback that mends itself is the debug log's.
66 on('ui.fault', async ($, e, next) => {
67 faults += 1
68 const retryMs = faultRetryMs(e.phase, faults)
69 const what = `drawn control ${e.element} (${e.module}) failed at ${e.phase} on ${e.surface}: ${e.reason}`
70 await update($, isClientDown, () => true)
71 if (retryMs === null) {
72 $.ui.log(`fishStack: ${what}. Buttons stand in until the next session.`)
73 } else {
74 $.ui.log(`${what}; Buttons stand in, trying again in ${retryMs / 1000}s`, { to: 'debug' })
75 $.clock.after(retryMs, () => void update($, isClientDown, () => false))
76 }
77 return next(e)
78 })
79
80 // With every card closed, the pane is their heads alone; an open card's
81 // file draws the pane instead.
82 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
83 if ((await read($, tab)) !== null) return next(e)
84 const ui: Ui = resolveUi($.ui.resolve(e), e.surface, await read($, isClientDown))
85 return frame(ui, null, e.props.bodyColumns, (id: Tab | null) => void update($, tab, () => id), null, await paneHeads($))
86 })
87
88 on('command.run', { command: 'fishstack' }, async ($, e) => openCard($, e.args))
89 on('command.run', { command: 'fs' }, async ($, e) => openCard($, e.args))
90}
91hooks/session.ts 347 lines1// What every session loads, and what the model is told: the read-only head
2// start, the dials' working style and the context library's enabled sources.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On, PluginOptions, PromptComposeSection, Timer } from 'claude-code'
5
6import type { AutopilotWait, DialValues, Folder, Source } from '../types'
7import { CONTEXT_ON_KEY, CONTEXT_SECTION_ID, contextSection, legacyStoreKey, libraryStoreKey, parseLibrary } from './context'
8import { BALANCED, brief, DIALS_ON_KEY, DIALS_SECTION_ID, DIALS_STORE_KEY, ENFORCE_KEY, isDialValues } from './dials'
9import { FISH_COMMANDS } from './pane'
10import { getSetting, settingsHome } from './settings'
11import { headStartNote, IDLE_WORKTREE, isReadOnlyPhase, MID_TURN_FROM } from './worktree'
12
13const dials = atom({ plugin: 'fishstack', key: 'dials' } as const, BALANCED)
14const folders = atom({ plugin: 'fishstack', key: 'folders' } as const, [])
15const sources = atom({ plugin: 'fishstack', key: 'sources' } as const, [])
16const worktree = atom({ plugin: 'fishstack', key: 'worktree' } as const, IDLE_WORKTREE)
17const isHeadStartPrompted = atom({ plugin: 'fishstack', key: 'isHeadStartPrompted' } as const, false)
18const owedMoveNote = atom({ plugin: 'fishstack', key: 'owedMoveNote' } as const, null)
19const toldStyle = atom({ plugin: 'fishstack', key: 'toldStyle' } as const, null)
20const isDialsOn = atom({ plugin: 'fishstack', key: 'isDialsOn' } as const, true)
21const isContextOn = atom({ plugin: 'fishstack', key: 'isContextOn' } as const, true)
22const isEnforced = atom({ plugin: 'fishstack', key: 'isEnforced' } as const, true)
23const autopilot = atom({ plugin: 'fishstack', key: 'autopilot' } as const, null)
24const railTick = atom({ plugin: 'fishstack', key: 'railTick' } as const, 0)
25const isClientDown = atom({ plugin: 'fishstack', key: 'isClientDown' } as const, false)
26
27// What the agent is told of the dials and the context, as one text: what a
28// change mid-session compares against. A tab switched off says so, so an
29// agent told earlier lets it go.
30function styleOf(values: DialValues, list: Source[], groups: Folder[], isDials: boolean, isContext: boolean): string {
31 return [
32 isDials ? brief(values) : 'The user turned the working style dials off: follow no Working style section.',
33 isContext ? (contextSection(list, groups) ?? 'The user shares no project context now.') : 'The user turned the shared project context off: follow no Project context section.',
34 ].join('\n\n')
35}
36
37async function styleNow($: EngineInterface): Promise<string> {
38 return styleOf(await read($, dials), await read($, sources), await read($, folders), await read($, isDialsOn), await read($, isContextOn))
39}
40
41// Between tool calls the note rides beside a tool's result, where a model
42// rightly distrusts instructions: it says where it comes from.
43function styleUpdate(style: string, isMidTurn = false): string {
44 const from = isMidTurn
45 ? `${MID_TURN_FROM}the user changed the working style dials or the shared project context in the fishStack pane while you worked. Apply it from your next step.`
46 : 'fishStack update: the user changed the working style dials or the shared project context during this session.'
47 return `${from} What follows replaces the Working style and Project context sections of your system prompt.\n\n${style}`
48}
49
50// The move note owed, once: whoever takes it tells the agent.
51async function takeMoveNote($: EngineInterface): Promise<string | null> {
52 const owed = await read($, owedMoveNote)
53 if (owed !== null) await update($, owedMoveNote, () => null)
54 return owed
55}
56
57// fishStack was Craftshop, whose saved dials, context libraries, slot leases
58// and Perm worktree switch sit in its own store file. The first session after
59// the rename copies them in, once, never over a value fishStack already has.
60// The plugin's name before the rename, which its store files start with.
61const FORMER_NAME = 'craft-shop'
62const MIGRATED_KEY = 'migratedFromCraftShop'
63
64async function migrateStore($: EngineInterface): Promise<string[]> {
65 if ((await $.store.get(MIGRATED_KEY)) !== undefined) return []
66 const home = await $.env.get('HOME')
67 const dir = `${home ?? ''}/.claude/plugins/store`
68 const files = (await $.fs.exists(dir)) ? (await $.fs.list(dir)).filter(one => one.kind === 'file' && one.name.startsWith(`${FORMER_NAME}_`) && one.name.endsWith('.json')) : []
69 const copied: string[] = []
70 for (const file of files) {
71 let saved: unknown
72 try {
73 saved = JSON.parse(String(await $.fs.read(`${dir}/${file.name}`)))
74 } catch {
75 continue
76 }
77 if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) continue
78 for (const [key, value] of Object.entries(saved)) {
79 const now = await $.store.get(key)
80 if (now !== undefined && now !== null) continue
81 await $.store.set(key, value as never)
82 copied.push(key)
83 }
84 }
85 await $.store.set(MIGRATED_KEY, files.map(one => one.name))
86 return copied
87}
88
89// Autopilot answers for a person who is away: a question the agent asked
90// that nobody has answered for autoAnswerMinutes (15 by default) takes, for
91// each choice question, the option labelled (Recommended), or else the first.
92// Text and number questions are left for the agent to settle.
93const AUTO_ANSWER_MINUTES = 15
94
95type AskedQuestion = {
96 question: string
97 kind?: 'choice' | 'text' | 'number'
98 options?: { label: string }[]
99 defaultValue?: number
100}
101
102export function recommendedAnswers(questions: AskedQuestion[]): Record<string, string> {
103 const answers: Record<string, string> = {}
104 for (const one of questions) {
105 if (one.kind === 'number' && one.defaultValue !== undefined) answers[one.question] = String(one.defaultValue)
106 if ((one.kind ?? 'choice') !== 'choice' || one.options === undefined || one.options.length === 0) continue
107 const marked = one.options.find(option => /\(recommended\)/i.test(option.label))
108 answers[one.question] = (marked ?? one.options[0]!).label
109 }
110 return answers
111}
112
113// The picks as the rail and the notice say them: each answer without its
114// "(Recommended)", the questions' answers joined.
115export function pickedText(answers: Record<string, string>): string {
116 return Object.values(answers)
117 .map(answer => answer.replace(/\s*\(recommended\)\s*/i, ' ').trim())
118 .join(', ')
119}
120
121// Minutes left as a person reads them: "in 14 min", "in under a minute".
122function minutesLeft(ms: number): string {
123 return ms < 60_000 ? 'in under a minute' : `in ${Math.ceil(ms / 60_000)} min`
124}
125
126// What Autopilot answered last in this session, kept in the store so the rail
127// still says so after a reload, until the person types again.
128export const AUTOPILOT_STORE_KEY = 'autopilot'
129type AutopilotKept = { sessionId: string; answered: AutopilotWait & { kind: 'answered' } }
130
131// The questions Autopilot is waiting on in this process, by call: the rail
132// shows the soonest, and how many.
133type Waiting = { id: string; since: number; deadline: number; picked: string }
134const waitingOn = new Map<string, Waiting>()
135let waitTick: Timer | null = null
136let calls = 0
137
138async function showWaiting($: EngineInterface): Promise<void> {
139 const soonest = [...waitingOn.values()].sort((a, b) => a.deadline - b.deadline)[0]
140 if (soonest !== undefined) {
141 await update($, autopilot, (): AutopilotWait => ({ kind: 'waiting', since: soonest.since, deadline: soonest.deadline, picked: soonest.picked, count: waitingOn.size }))
142 } else {
143 await update($, autopilot, now => (now?.kind === 'waiting' ? null : now))
144 }
145}
146
147// While anything waits, the rail redraws every 15 seconds so its ring runs
148// down, and each open dialog's line counts the minutes.
149function tickWhileWaiting($: EngineInterface): void {
150 if (waitingOn.size > 0 && waitTick === null) {
151 waitTick = $.clock.every(15_000, () => {
152 void (async () => {
153 const now = await $.clock.now()
154 for (const one of waitingOn.values()) notice($, one.id, `Autopilot picks ${one.picked} ${minutesLeft(one.deadline - now)} unless you answer`)
155 await update($, railTick, n => n + 1)
156 })()
157 })
158 }
159 if (waitingOn.size === 0 && waitTick !== null) {
160 waitTick.cancel()
161 waitTick = null
162 }
163}
164
165// A line under the question's dialog: the rail is not drawn while a dialog
166// holds the composer (the terminal's), so the countdown is said there too.
167function notice($: EngineInterface, id: string, text: string | undefined): void {
168 if (id.startsWith('call-')) return
169 try {
170 $.ui.notice(id, text)
171 } catch {
172 // The dialog closed first.
173 }
174}
175
176// This repository's dials, switches and library, as the store keeps them.
177async function loadSaved($: EngineInterface): Promise<void> {
178 const storedDials = await getSetting($, DIALS_STORE_KEY)
179 await update($, dials, () => (isDialValues(storedDials) ? storedDials : BALANCED))
180 const useDials = await getSetting($, DIALS_ON_KEY)
181 await update($, isDialsOn, () => useDials !== false)
182 const useContext = await getSetting($, CONTEXT_ON_KEY)
183 await update($, isContextOn, () => useContext !== false)
184 const enforce = await getSetting($, ENFORCE_KEY)
185 await update($, isEnforced, () => enforce !== false)
186 // The library under the main checkout; before that it was kept under the
187 // folder the session started in, a worktree's own among them.
188 const home = await settingsHome($)
189 const repo = await $.session.repo()
190 const root = repo?.root ?? (await $.session.root())
191 const kept = await $.store.get(libraryStoreKey(home))
192 const library = parseLibrary(kept ?? (await $.store.get(libraryStoreKey(root))), await $.store.get(legacyStoreKey(root)))
193 await update($, sources, () => library.sources)
194 await update($, folders, () => library.folders)
195}
196
197// Where a prompt the person typed comes from: the terminal's composer, the
198// desktop app (through the SDK) or their phone (Remote Control).
199const PERSON_ORIGINS = new Set(['composer', 'sdk', 'bridge'])
200
201export function registerSession(on: On, options: PluginOptions): void {
202 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
203 const input = e as unknown as { questions?: AskedQuestion[]; metadata?: { source?: string } }
204 // Only the agent's own questions: one a plugin raised ($.ui.ask, as
205 // fishStack asks where a session works) or one with a source is the
206 // person's, not Autopilot's to answer.
207 const isAgents = next.origin.plugin === 'engine' && input.metadata?.source === undefined
208 if (!isAgents || !(await read($, isDialsOn)) || (await read($, dials)).autonomy < 5 || input.questions === undefined) return next(e)
209 const ms = Math.max(0, Number(options.autoAnswerMinutes ?? AUTO_ANSWER_MINUTES)) * 60_000
210 const id = e.tool_use_id ?? `call-${++calls}`
211 const since = await $.clock.now()
212 const picked = pickedText(recommendedAnswers(input.questions)) || 'the recommended options'
213 const answered = next(e)
214 let timer: Timer | null = null
215 const away = new Promise<'away'>(resolve => {
216 timer = $.clock.after(ms, () => resolve('away'))
217 })
218 waitingOn.set(id, { id, since, deadline: since + ms, picked })
219 await showWaiting($)
220 tickWhileWaiting($)
221 // After the dialog has opened: a line for a call not yet open is refused.
222 $.clock.after(0, () => notice($, id, `Autopilot picks ${picked} ${minutesLeft(ms)} unless you answer`))
223 try {
224 const first = await Promise.race([answered, away])
225 if (first !== 'away') return first
226 // The dial may have come down while the question waited: read where a
227 // change is saved, as this hook's own reads hold what it began with.
228 const now = await getSetting($, DIALS_STORE_KEY)
229 if ((isDialValues(now) && now.autonomy < 5) || (await getSetting($, DIALS_ON_KEY)) === false) {
230 waitingOn.delete(id)
231 await showWaiting($)
232 notice($, id, undefined)
233 return await answered
234 }
235 const answers = recommendedAnswers(input.questions)
236 const at = await $.clock.now()
237 const done: AutopilotWait = { kind: 'answered', at, picked, waitedMs: ms }
238 waitingOn.delete(id)
239 await update($, autopilot, () => done)
240 await showWaiting($)
241 await $.store.set(AUTOPILOT_STORE_KEY, { sessionId: await $.session.id(), answered: done })
242 void $.ui.notify(`Autopilot answered for you after ${Math.round(ms / 60_000)} min away: ${Object.values(answers).join('; ')}`, { title: 'fishStack' }).catch(() => undefined)
243 return { result: { questions: input.questions, answers, afkTimeoutMs: ms } }
244 } finally {
245 ;(timer as Timer | null)?.cancel()
246 if (waitingOn.delete(id)) await showWaiting($)
247 tickWhileWaiting($)
248 }
249 })
250
251 on('session.start', async ($, e, next) => {
252 // The commands first, so /fishstack answers from the first keystroke, not
253 // after the worktree claim and the store reads below.
254 for (const command of FISH_COMMANDS) await $.command.register(command)
255 // Before the rest of the chain, so every later hook reads what came over
256 // from Craftshop's store.
257 await migrateStore($)
258 // A load tries the Clients again that a fault (pane.tsx) put down.
259 await update($, isClientDown, () => false)
260 const started = await next(e)
261 await loadSaved($)
262 const kept = (await $.store.get(AUTOPILOT_STORE_KEY)) as AutopilotKept | null | undefined
263 if (kept?.sessionId === (await $.session.id()) && (await read($, autopilot)) === null) await update($, autopilot, () => kept.answered)
264 return started
265 })
266
267 // A /clear starts the state over with no session.start: the session after
268 // it gets the dials and the library back from the store (worktree.tsx
269 // carries the worktree over).
270 on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
271 const done = await next(e)
272 await loadSaved($)
273 return done
274 })
275
276 on('prompt.compose', async ($, e, next) => {
277 const composed = await next(e)
278 const values = await read($, dials)
279 const list = await read($, sources)
280 const groups = await read($, folders)
281 const isDials = await read($, isDialsOn)
282 const isContext = await read($, isContextOn)
283 // The engine keeps a session's first system prompt for its requests, so
284 // the style first composed for a request (not for /context's measure) is
285 // the one the model holds until prompt.submit tells it otherwise.
286 if (!e.traits.includes('analysis') && (await read($, toldStyle)) === null) {
287 await update($, toldStyle, () => styleOf(values, list, groups, isDials, isContext))
288 }
289
290 // A tab switched off adds nothing.
291 const added: PromptComposeSection[] = isDials ? [{ id: DIALS_SECTION_ID, text: brief(values), scope: 'session' }] : []
292 const shared = isContext ? contextSection(list, groups) : null
293 if (shared !== null) added.push({ id: CONTEXT_SECTION_ID, text: shared, scope: 'session' })
294 return { sections: [...composed.sections, ...added] }
295 })
296
297 on('prompt.submit', async ($, e, next) => {
298 // The person is back: what Autopilot answered for them has been seen.
299 // A notification or a peer's message is not them.
300 if (PERSON_ORIGINS.has(e.origin.kind) && (await read($, autopilot))?.kind === 'answered') {
301 await update($, autopilot, () => null)
302 await $.store.set(AUTOPILOT_STORE_KEY, null)
303 }
304 const notes: string[] = []
305 const { phase, main } = await read($, worktree)
306 if (isReadOnlyPhase(phase) && main !== null) {
307 notes.push(headStartNote(main))
308 await update($, isHeadStartPrompted, () => true)
309 }
310 const moved = await takeMoveNote($)
311 if (moved !== null) notes.push(`fishStack update: ${moved}`)
312 const told = await read($, toldStyle)
313 const style = await styleNow($)
314 if (told !== null && told !== style) {
315 notes.push(styleUpdate(style))
316 await update($, toldStyle, () => style)
317 }
318 return notes.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...notes] })
319 })
320
321 // A change made mid-turn reaches the agent before its next request, not at
322 // the next prompt: once a batch of tool calls resolves, the note rides with
323 // it as added context, after everything cached, so the cache holds. The
324 // main loop's batches only: a subagent told would mark it told for nobody.
325 // So does a head start that began after the prompt (the desktop attaching
326 // late), and a move the agent was not warned of.
327 on('classic.PostToolBatch', async ($, e, next) => {
328 const done = await next(e)
329 if (e.agent_id !== undefined) return done
330 const notes: string[] = []
331 const { phase, main } = await read($, worktree)
332 if (isReadOnlyPhase(phase) && main !== null && !(await read($, isHeadStartPrompted))) {
333 notes.push(headStartNote(main, true))
334 await update($, isHeadStartPrompted, () => true)
335 }
336 const moved = await takeMoveNote($)
337 if (moved !== null) notes.push(`${MID_TURN_FROM}${moved}`)
338 const told = await read($, toldStyle)
339 const style = await styleNow($)
340 if (told !== null && told !== style) {
341 notes.push(styleUpdate(style, true))
342 await update($, toldStyle, () => style)
343 }
344 return notes.length === 0 ? done : { ...done, additionalContext: [...(done.additionalContext ?? []), ...notes] }
345 })
346}
347hooks/worktree.tsx 1747 lines1// Auto-claims a worktree slot for a session started in a main checkout, a
2// simplified port of CommissionAI's head start (service/src/threads.rs) and
3// slots (worktrees.rs). Until the slot is ready the session runs read-only
4// in the main checkout (see guard.ts). This file also draws the band above
5// the prompt and the pane's Worktree tab.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
8
9import type { Slot, Tab, Worktree, WorktreePhase } from '../types'
10import { BAND_CARD_COLUMNS, cardOf, frame, PANE, PANE_COLUMNS, PANE_TITLE, resolveUi } from './ui'
11import type { Ui } from './ui'
12import { BALANCED, dialsBody, presetName } from './dials'
13import { contextBody } from './context'
14import { segmented, switchRow } from './kit'
15import { beginTurn } from './enforce'
16import { drawRail } from './rail'
17import { getSetting, repoMain, setSetting } from './settings'
18import { IDLE_WORKTREE, paneHeads } from './state'
19
20export { IDLE_WORKTREE }
21
22const isHeadStartPrompted = atom({ plugin: 'fishstack', key: 'isHeadStartPrompted' } as const, false)
23const isWhereAsked = atom({ plugin: 'fishstack', key: 'isWhereAsked' } as const, false)
24const isTurnRunning = atom({ plugin: 'fishstack', key: 'isTurnRunning' } as const, false)
25const owedMoveNote = atom({ plugin: 'fishstack', key: 'owedMoveNote' } as const, null)
26const slots = atom({ plugin: 'fishstack', key: 'slots' } as const, [])
27const tab = atom({ plugin: 'fishstack', key: 'tab' } as const, null)
28const isClientDown = atom({ plugin: 'fishstack', key: 'isClientDown' } as const, false)
29const worktree = atom({ plugin: 'fishstack', key: 'worktree' } as const, IDLE_WORKTREE)
30const isStepsShown = atom({ plugin: 'fishstack', key: 'isStepsShown' } as const, false)
31const isSlotsShown = atom({ plugin: 'fishstack', key: 'isSlotsShown' } as const, false)
32const dials = atom({ plugin: 'fishstack', key: 'dials' } as const, BALANCED)
33const sources = atom({ plugin: 'fishstack', key: 'sources' } as const, [])
34const lastReplyAt = atom({ plugin: 'fishstack', key: 'lastReplyAt' } as const, null)
35// The rail pill under the pointer, kept to this process: a hover is one
36// window's, not the session's.
37let railHover: string | null = null
38// Bumped by a rail hover and the cache gauge's tick; only the band reads it,
39// so neither redraws the pane.
40const railTick = atom({ plugin: 'fishstack', key: 'railTick' } as const, 0)
41// The rail's figures for one session, kept in the store under one key.
42const RAIL_STORE_KEY = 'rail'
43type RailKept = { sessionId: string; repliedAt: number | null; tokens: { input: number; output: number; cacheRead?: number; cacheWrite?: number } | null }
44// The worktree this session works in when it is not ours to manage (another
45// tool's, or one made by hand); null in the main checkout or a slot of ours.
46const elsewhere = atom({ plugin: 'fishstack', key: 'elsewhere' } as const, null)
47// What the pool's background warming is doing, for the Worktrees tab.
48const poolNote = atom({ plugin: 'fishstack', key: 'poolNote' } as const, '')
49const slotAsk = atom({ plugin: 'fishstack', key: 'slotAsk' } as const, null)
50const tokenTotals = atom({ plugin: 'fishstack', key: 'tokenTotals' } as const, null)
51// Autopilot answering for a person who is away (session.ts sets it).
52const autopilot = atom({ plugin: 'fishstack', key: 'autopilot' } as const, null)
53const isDialsOn = atom({ plugin: 'fishstack', key: 'isDialsOn' } as const, true)
54const isContextOn = atom({ plugin: 'fishstack', key: 'isContextOn' } as const, true)
55
56type $ = EngineInterface
57
58const LEASE_PREFIX = 'lease:'
59const HEARTBEAT_MS = 60_000
60const LEASE_TTL_MS = 3 * HEARTBEAT_MS
61const SETUP_TIMEOUT_MS = 10 * 60_000
62const LOCK_STALE_MS = 30_000
63// As CommissionAI's: three slots ready, and no warming below 20 GB free
64// (the minFreeGb option).
65export const POOL_SIZE = 3
66const DEFAULT_MIN_FREE_GB = 20
67const SETUP_PREFIX = 'setup:'
68type SetupRun = { command: string; lockHash: string }
69// A slot the pool is making, by path: who reserved it and what it is doing,
70// so every session lists it as setting up and none claims it half made.
71// Queued is reserved, not yet started. A run that died leaves its mark,
72// which lapses after the longest a step may take.
73const WARMING_PREFIX = 'warming:'
74const QUEUED = 'Queued'
75const WARMING_TTL_MS = SETUP_TIMEOUT_MS + LEASE_TTL_MS
76type Warming = { sessionId: string; step: string; at: number }
77// How many slots the pool keeps, by slot root: + Add slot raises it and
78// Remove lowers it; POOL_SIZE until either is pressed.
79const POOL_SIZE_PREFIX = 'poolSize:'
80const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']
81const IN_REPO_ROOT = '.claude/worktrees/fishstack'
82
83export const READ_ONLY_DENY =
84 'Read-only until the worktree is ready. fishStack is preparing a worktree for this session; read, search and plan for now, and you will be moved there with full access.'
85
86// What a note told between tool calls says first: it rides beside a tool's
87// result, where a model rightly distrusts instructions.
88export const MID_TURN_FROM = 'fishStack update, from the hook of fishStack, a plugin the user installed, not from the tool you just ran: '
89
90export function headStartNote(main: string, isMidTurn = false): string {
91 return `${isMidTurn ? MID_TURN_FROM : ''}Read-only head start: your worktree is still being prepared, so for now you run read-only in the main checkout at \`${main}\`. It may sit on another branch or be behind origin, so treat it as a reference. Investigate, read code and plan; do not edit files, commit or run commands that change anything. When your plan is ready, write it in your reply and end your turn. When the worktree is ready, fishStack moves you there with full access and tells you to continue.`
92}
93
94function continueNote(path: string, branch: string): string {
95 return `Your worktree is ready at \`${path}\` on branch \`${branch}\`, and the read-only head start is over. You now work there with full access. Carry on with the request from where you left off, starting with the plan you made. If you ended your last turn waiting for an answer, end this turn and keep waiting instead.`
96}
97
98// A move the agent was not warned of, as a head start told before it would;
99// whoever delivers it says first where it comes from.
100function movedNote(path: string, branch: string): string {
101 return `this session has moved into its own worktree at \`${path}\` on branch \`${branch}\`, and works there from now on, with full access. Relative paths and commands now run there, not in the main checkout; anything you said about where you work before this is out of date.`
102}
103
104type Lease = { sessionId: string; beatAt: number }
105
106class StepError extends Error {}
107
108export function isReadOnlyPhase(phase: Worktree['phase']): boolean {
109 return phase === 'preparing' || phase === 'failed'
110}
111
112type GitRun = { timeoutMs?: number; env?: Record<string, string> }
113
114async function git($: $, args: string[], cwd: string, { timeoutMs = 120_000, env }: GitRun = {}) {
115 return $.process.run(['git', ...args], env === undefined ? { cwd, timeoutMs } : { cwd, timeoutMs, env })
116}
117
118async function gitOut($: $, args: string[], cwd: string, failure: string, run?: GitRun): Promise<string> {
119 // A git still running at its timeout rejects: that failed too.
120 const ran = await git($, args, cwd, run).catch((error: unknown) => {
121 $.ui.log(`git ${args.join(' ')} did not finish: ${String(error)}`, { to: 'debug' })
122 throw new StepError(`${failure} Git did not finish in time.`)
123 })
124 if (ran.exitCode !== 0) {
125 $.ui.log(`git ${args.join(' ')} failed: ${ran.stderr.trim()}`, { to: 'debug' })
126 // Git's last line says why, which the step alone cannot.
127 const why = ran.stderr.trim().split('\n').pop()?.trim() ?? ''
128 throw new StepError(why === '' ? failure : `${failure} ${why.slice(0, 160)}`)
129 }
130 return ran.stdout.trim()
131}
132
133async function hash6(text: string): Promise<string> {
134 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
135 return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('').slice(0, 6)
136}
137
138async function realPath($: $, path: string): Promise<string> {
139 return (await $.fs.stat(path, { resolve: true })).realPath ?? path
140}
141
142async function expandHome($: $, path: string): Promise<string> {
143 if (!path.startsWith('~/')) return path
144 const home = await $.env.get('HOME')
145 if (home === undefined) throw new StepError('HOME is not set, so the worktree root cannot be found.')
146 return `${home}${path.slice(1)}`
147}
148
149// Where a repository's slots live: by default inside its main checkout, under
150// .claude/worktrees/ where Claude Code keeps its own, so EnterWorktree moves in
151// without asking; with a worktreeRoot set, under <root>/<repo>-<hash>.
152async function slotsRoot($: $, options: PluginOptions, main: string): Promise<string> {
153 if (!options.worktreeRoot) return `${main}/${IN_REPO_ROOT}`
154 const root = await expandHome($, String(options.worktreeRoot))
155 const name = main.split('/').pop() ?? 'repo'
156 return `${root}/${name}-${await hash6(main)}`
157}
158
159// Whether a session's worktree is one of the pool's slots, leased and
160// released, or the desktop app's own, set up where it is (see startWorktree).
161async function isSlot($: $, options: PluginOptions, main: string, path: string): Promise<boolean> {
162 return path.startsWith(`${await slotsRoot($, options, main)}/`)
163}
164
165// A slot root inside the main checkout stays out of its git status: unless the
166// repository ignores it already, it goes in the checkout's own info/exclude.
167async function excludeFromMain($: $, main: string, path: string): Promise<void> {
168 if (!path.startsWith(`${main}/`)) return
169 await keepOutOfGit($, main, `${path.slice(main.length + 1, path.lastIndexOf('/'))}/`)
170}
171
172// A path in the main checkout, relative, added to its own info/exclude
173// unless something ignores it already.
174async function keepOutOfGit($: $, main: string, relative: string): Promise<void> {
175 if ((await git($, ['check-ignore', '-q', relative], main)).exitCode !== 1) return
176 const found = await git($, ['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], main)
177 const exclude = found.stdout.trim()
178 if (found.exitCode !== 0 || exclude === '') return
179 await $.process.run(['/bin/bash', '-c', 'mkdir -p "$(dirname "$1")" && printf \'%s\\n\' "$2" >> "$1"', 'bash', exclude, `/${relative}`])
180}
181
182async function readLease($: $, path: string): Promise<Lease | null> {
183 const lease = await $.store.get(LEASE_PREFIX + path)
184 return lease !== null && typeof lease === 'object' ? (lease as Lease) : null
185}
186
187async function isLeaseLive($: $, lease: Lease | null): Promise<boolean> {
188 return lease !== null && (await $.clock.now()) - lease.beatAt < LEASE_TTL_MS
189}
190
191async function writeLease($: $, path: string): Promise<void> {
192 const lease: Lease = { sessionId: await $.session.id(), beatAt: await $.clock.now() }
193 await $.store.set(LEASE_PREFIX + path, lease)
194}
195
196// The desktop app's hold on a slot, from desktop-worktree.sh: a file beside
197// the slot, kept from the session's start until the desktop removes its
198// worktree, so it outlasts the session's process, which a lease does not.
199// A slot is offered to that hook by its .ready file.
200const HELD = '.held'
201const READY = '.ready'
202
203async function isHeld($: $, path: string): Promise<boolean> {
204 return $.fs.exists(path + HELD)
205}
206
207// Held by the desktop, or leased by a live session.
208async function isTaken($: $, path: string): Promise<boolean> {
209 return (await isHeld($, path)) || (await isLeaseLive($, await readLease($, path)))
210}
211
212async function readWarming($: $, path: string): Promise<Warming | null> {
213 const mark = await $.store.get(WARMING_PREFIX + path)
214 return mark !== null && typeof mark === 'object' ? (mark as Warming) : null
215}
216
217async function isWarmingLive($: $, mark: Warming | null): Promise<boolean> {
218 return mark !== null && (await $.clock.now()) - mark.at < WARMING_TTL_MS
219}
220
221// Marks a slot setting up at this step, in the store and the pool's signal
222// for every session, and in this one's list at once.
223async function markWarming($: $, path: string, step: string): Promise<void> {
224 await $.store.set(WARMING_PREFIX + path, { sessionId: await $.session.id(), step, at: await $.clock.now() })
225 await signalPool($, path)
226 await update($, slots, list => list.map(one => (one.path === path ? { ...one, setup: step } : one)))
227}
228
229async function poolSize($: $, repoDir: string): Promise<number> {
230 const kept = await $.store.get(POOL_SIZE_PREFIX + repoDir)
231 return typeof kept === 'number' ? kept : POOL_SIZE
232}
233
234async function setStep($: $, label: string, isWarning = false): Promise<void> {
235 const at = await $.clock.now()
236 await update($, worktree, current => ({
237 ...current,
238 step: label,
239 steps: [...current.steps, { label, at, isWarning }],
240 }))
241}
242
243async function worktreePaths($: $, main: string): Promise<Map<string, string | null>> {
244 const listed = await gitOut($, ['worktree', 'list', '--porcelain'], main, 'Could not list worktrees.')
245 const paths = new Map<string, string | null>()
246 let path: string | null = null
247 for (const line of listed.split('\n')) {
248 if (line.startsWith('worktree ')) {
249 path = line.slice('worktree '.length)
250 paths.set(path, null)
251 } else if (line.startsWith('branch ') && path !== null) {
252 paths.set(path, line.slice('branch refs/heads/'.length))
253 }
254 }
255 return paths
256}
257
258// The numbered slot folders under a repository's slot root, in order.
259async function slotDirs($: $, repoDir: string): Promise<string[]> {
260 const found = await $.process.run(['find', repoDir, '-mindepth', '1', '-maxdepth', '1', '-type', 'd'])
261 if (found.exitCode !== 0) return []
262 return found.stdout
263 .split('\n')
264 .map(line => line.trim())
265 .filter(line => /\/\d+$/.test(line))
266 .sort((a, b) => Number(a.split('/').pop()) - Number(b.split('/').pop()))
267}
268
269async function refreshSlots($: $, options: PluginOptions): Promise<void> {
270 const main = (await read($, worktree)).main ?? (await repoMain($))
271 if (main !== null) await refreshSlotsFor($, options, main)
272}
273
274// The soonest a lease of another session lapses unrenewed, when the Slots
275// list is next checked: one timer, not a poll, so a session that died
276// shows its slot free on time.
277let lapseTimer: Timer | null = null
278
279async function refreshSlotsFor($: $, options: PluginOptions, main: string): Promise<void> {
280 const { path: mine } = await read($, worktree)
281 const repoDir = await slotsRoot($, options, main)
282 const listed = await worktreePaths($, main)
283 const now = await $.clock.now()
284 const found: Slot[] = []
285 let lapsesAt = Infinity
286 for (const path of await slotDirs($, repoDir)) {
287 const real = await realPath($, path)
288 const mark = await readWarming($, path)
289 const isWarming = await isWarmingLive($, mark)
290 // A slot being made is listed before git knows it, so it shows at once.
291 if (!listed.has(real) && !isWarming) continue
292 const lease = await readLease($, path)
293 const isLive = await isLeaseLive($, lease)
294 const isLeased = isLive || (await isHeld($, path))
295 if (isLive && path !== mine) lapsesAt = Math.min(lapsesAt, lease!.beatAt + LEASE_TTL_MS)
296 found.push({ path, branch: listed.get(real) ?? null, isLeased, isMine: path === mine, setup: isWarming ? mark!.step : null, isRemoving: removing.has(path) })
297 }
298 lapseTimer?.cancel()
299 lapseTimer = Number.isFinite(lapsesAt) ? $.clock.after(Math.max(1_000, lapsesAt - now), () => void refreshSlotsFor($, options, main)) : null
300 // Unchanged, the pane is not drawn again.
301 if (JSON.stringify(await read($, slots)) !== JSON.stringify(found)) await update($, slots, () => found)
302}
303
304// The pool's change signal, a file beside the slots: every claim, release
305// and new slot rewrites it, and each session has Claude Code's own file
306// watcher on it (armed at session start), so the Slots list follows other
307// sessions as they go, with no polling.
308const POOL_SIGNAL = '.changed'
309
310async function signalPool($: $, slot: string): Promise<void> {
311 await $.fs.write(`${slot.slice(0, slot.lastIndexOf('/'))}/${POOL_SIGNAL}`, String(await $.clock.now())).catch(() => undefined)
312}
313
314// What a session watches for the Slots list: the pool's signal, and each
315// slot's HEAD, so a branch checked out in a slot shows too. None while the
316// repository has no pool.
317async function poolWatchPaths($: $, options: PluginOptions): Promise<string[]> {
318 const main = await repoMain($)
319 if (main === null) return []
320 const repoDir = await slotsRoot($, options, main)
321 const dirs = await slotDirs($, repoDir)
322 if (dirs.length === 0) return []
323 const signal = `${repoDir}/${POOL_SIGNAL}`
324 if (!(await $.fs.exists(signal))) await $.fs.write(signal, String(await $.clock.now()))
325 const heads: string[] = []
326 for (const slot of dirs) {
327 const gitdir = await $.fs.read(`${slot}/.git`).catch(() => '')
328 const admin = /^gitdir: (.+)$/m.exec(String(gitdir))?.[1]?.trim()
329 if (admin !== undefined) heads.push(`${admin}/HEAD`)
330 }
331 return [signal, ...heads]
332}
333
334// Changes come in bursts (a claim writes its lease, then its branch): one
335// refresh for each burst.
336let isRefreshQueued = false
337
338function queueRefresh($: $, options: PluginOptions): void {
339 if (isRefreshQueued) return
340 isRefreshQueued = true
341 $.clock.after(250, () => {
342 isRefreshQueued = false
343 void refreshSlots($, options)
344 })
345}
346
347// Slots this process is removing, so a redraw meanwhile still says so.
348const removing = new Set<string>()
349
350// A slot left on the default branch (checked out by hand, past the guard)
351// lets go of it as its session ends: the main checkout may want it, and git
352// gives a branch to one worktree at a time. Uncommitted work stays in place.
353async function freeDefaultBranch($: $, main: string, path: string): Promise<void> {
354 try {
355 const on = await git($, ['symbolic-ref', '--short', '-q', 'HEAD'], path)
356 if (on.exitCode !== 0 || on.stdout.trim() !== (await defaultBranch($, main))) return
357 await git($, ['-c', 'core.hooksPath=/dev/null', 'checkout', '-q', '--detach'], path)
358 } catch (error) {
359 $.ui.log(`default branch not freed in ${path}: ${String(error)}`, { to: 'debug' })
360 }
361}
362
363async function releaseSlot($: $, path: string): Promise<void> {
364 await $.store.delete(LEASE_PREFIX + path)
365 await signalPool($, path)
366}
367
368// A git write: never runs the repository's hooks, as CommissionAI's do not.
369// Writes check out, reset and remove whole trees, which in a big repository
370// take minutes, so they get the setup command's time, not a read's. And in
371// a blobless clone a checkout needs blobs it lacks: Claude Code runs git
372// with GIT_NO_LAZY_FETCH=1, under which such a checkout grinds until it is
373// killed, so writes let git fetch them from origin, as the fetch before
374// them does.
375const GIT_WRITE: GitRun = { timeoutMs: SETUP_TIMEOUT_MS, env: { GIT_NO_LAZY_FETCH: '0' } }
376
377async function gitWrite($: $, args: string[], cwd: string, failure: string): Promise<string> {
378 return gitOut($, ['-c', 'core.hooksPath=/dev/null', ...args], cwd, failure, GIT_WRITE)
379}
380
381// One claim at a time per repository, across sessions: an atomic mkdir, and a
382// lock older than LOCK_STALE_MS (a session that died holding it) is broken.
383async function lock($: $, repoDir: string): Promise<void> {
384 await $.process.run(['mkdir', '-p', repoDir])
385 const lockDir = `${repoDir}/.lock`
386 for (let tries = 0; tries < 300; tries++) {
387 if ((await $.process.run(['mkdir', lockDir])).exitCode === 0) return
388 const held = await $.fs.stat(lockDir).catch(() => null)
389 if (held === null || (await $.clock.now()) - held.mtimeMs > LOCK_STALE_MS) {
390 await $.process.run(['rm', '-rf', lockDir])
391 continue
392 }
393 await $.clock.sleep(100)
394 }
395 throw new StepError('Another session held the worktree lock for too long.')
396}
397
398async function unlock($: $, repoDir: string): Promise<void> {
399 await $.process.run(['rm', '-rf', `${repoDir}/.lock`])
400}
401
402// A fresh slot as CommissionAI makes one: detached and unchecked-out at the
403// base, then checked out with stat checks kept minimal, then the index warmed
404// so the session's first git status is quick.
405async function createSlot($: $, main: string, path: string, sha: string): Promise<void> {
406 await excludeFromMain($, main, path)
407 await git($, ['worktree', 'prune'], main)
408 await gitWrite($, ['worktree', 'add', '-q', '--detach', '--no-checkout', path, sha], main, 'Could not create the worktree.')
409 try {
410 await gitWrite($, ['-c', 'core.checkStat=minimal', 'reset', '--hard', '-q'], path, 'Could not check out the new worktree.')
411 } catch (error) {
412 // A checkout cut short is no slot to scrub and reuse: remove it.
413 await git($, ['worktree', 'remove', '--force', '--force', path], main, GIT_WRITE)
414 throw error
415 }
416 await git($, ['status', '--porcelain=v1', '-z', '--untracked-files=all'], path)
417}
418
419// Anything left uncommitted in a slot, kept under refs/fish/backup/.
420async function backUpLeftovers($: $, path: string): Promise<void> {
421 const status = await git($, ['status', '--porcelain'], path)
422 if (status.exitCode === 0 && status.stdout.trim() !== '') {
423 await gitWrite($, ['add', '-A'], path, 'Could not save the slot\'s leftover work.')
424 const stash = await git($, ['stash', 'create'], path)
425 const sha = stash.stdout.trim()
426 if (stash.exitCode === 0 && sha !== '') {
427 await gitWrite($, ['update-ref', `refs/fish/backup/${path.split('/').pop()}-${await $.clock.now()}`, sha], path, 'Could not back up the slot\'s leftover work.')
428 }
429 }
430}
431
432// Readies a used slot for its next session: its leftovers backed up, then
433// the slot is reset, cleaned (ignored files such as node_modules stay) and
434// detached, which frees its old branch.
435async function scrubSlot($: $, path: string): Promise<void> {
436 await backUpLeftovers($, path)
437 await gitWrite($, ['reset', '--hard', '-q'], path, 'Could not reset the slot.')
438 await gitWrite($, ['clean', '-fdq'], path, 'Could not clean the slot.')
439 await gitWrite($, ['checkout', '-q', '--detach', 'HEAD'], path, 'Could not detach the slot.')
440}
441
442// Picks a slot under the lock: the one this session already leases (a retry
443// comes back to it), else an idle one (listed, not held, no live lease), one
444// offered to the desktop's hook last, else the next number, reserved by its
445// empty folder. Leased before the lock lifts.
446async function pickSlot($: $, options: PluginOptions, main: string): Promise<{ path: string; isNew: boolean; isMine: boolean }> {
447 const repoDir = await slotsRoot($, options, main)
448 await lock($, repoDir)
449 try {
450 const listed = await worktreePaths($, main)
451 const sessionId = await $.session.id()
452 const dirs = await slotDirs($, repoDir)
453 const free: { path: string; isMine: boolean }[] = []
454 const offered: { path: string; isMine: boolean }[] = []
455 for (const path of dirs) {
456 if (!listed.has(await realPath($, path))) continue
457 if (await isWarmingLive($, await readWarming($, path))) continue
458 if (await isHeld($, path)) continue
459 const lease = await readLease($, path)
460 const isMine = lease?.sessionId === sessionId
461 if (!isMine && (await isLeaseLive($, lease))) continue
462 ;((await $.fs.exists(path + READY)) ? offered : free).push({ path, isMine })
463 }
464 const picked = [...free, ...offered].find(one => one.isMine) ?? free[0] ?? offered[0]
465 if (picked !== undefined) {
466 await $.process.run(['rm', '-f', picked.path + READY])
467 await writeLease($, picked.path)
468 await signalPool($, picked.path)
469 return { ...picked, isNew: false }
470 }
471 const next = dirs.reduce((most, path) => Math.max(most, Number(path.split('/').pop())), 0) + 1
472 const path = `${repoDir}/${next}`
473 await $.process.run(['mkdir', '-p', path])
474 await writeLease($, path)
475 await signalPool($, path)
476 return { path, isNew: true, isMine: false }
477 } finally {
478 await unlock($, repoDir)
479 }
480}
481
482// The slot's branch, this session's alone: the name asked for while no branch
483// has it, or while this session's own slot is on it (a retry); else that name
484// with the session's id. Claude Code reuses a worktree folder for a new
485// session, so a name taken from the folder does not say whose a branch is,
486// and checkout -B over another session's branch would reset its commits away.
487async function freeBranch($: $, main: string, path: string, isMine: boolean, wanted: string): Promise<string> {
488 const own = isMine ? ((await worktreePaths($, main)).get(await realPath($, path)) ?? null) : null
489 const id = (await $.session.id()).slice(0, 6)
490 for (const name of [wanted, `${wanted}-${id}`]) {
491 if (name === own) return name
492 if ((await git($, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], main)).exitCode !== 0) return name
493 }
494 return `${wanted}-${id}-${(await $.clock.now()).toString(36)}`
495}
496
497async function claimSlot($: $, options: PluginOptions, main: string, base: string, baseSha: string, wanted: string) {
498 const { path, isNew, isMine } = await pickSlot($, options, main)
499 const branch = await freeBranch($, main, path, isMine, wanted)
500 if (isNew) {
501 await setStep($, 'Creating a new worktree')
502 await createSlot($, main, path, baseSha)
503 } else {
504 await setStep($, `Reusing slot ${path.split('/').pop()}`)
505 await scrubSlot($, path)
506 }
507 await gitWrite($, ['checkout', '-q', '-B', branch, base], path, 'Could not check out the base branch in the slot.')
508 await signalPool($, path)
509 return { path, branch }
510}
511
512// What the dependencies are pinned to: the git hashes of the lockfiles there.
513async function lockHash($: $, dir: string): Promise<string> {
514 const parts: string[] = []
515 for (const lockfile of LOCKFILES) {
516 if (!(await $.fs.exists(`${dir}/${lockfile}`))) continue
517 const hashed = await git($, ['hash-object', lockfile], dir)
518 parts.push(`${lockfile}:${hashed.stdout.trim()}`)
519 }
520 return parts.join(',')
521}
522
523// Every node_modules under a folder, six deep at most, skipping dot folders
524// and build output; relative paths.
525async function nodeModulesIn($: $, dir: string): Promise<string[]> {
526 const found = await $.process.run(
527 ['find', dir, '-maxdepth', '6', '(', '-name', '.*', '-o', '-name', 'target', ')', '-prune', '-o', '-type', 'd', '-name', 'node_modules', '-print', '-prune'],
528 { timeoutMs: 60_000 },
529 )
530 if (found.exitCode !== 0) return []
531 return found.stdout
532 .split('\n')
533 .filter(line => line.startsWith(`${dir}/`))
534 .map(line => line.slice(dir.length + 1))
535}
536
537// Seeds a slot's node_modules as CommissionAI does: cloned copy-on-write (APFS
538// clonefile through cp -c, so it costs no disk until a file changes) from an
539// idle sibling slot pinned to the same lockfiles, or else the main checkout,
540// into a staging folder renamed into place. Then the setup command, skipped
541// when it and the lockfiles are what it last ran with in this slot.
542// What seeding says as it goes: a session's claim logs each step for its
543// band and stepper; the pool's warming shows it on the slot's row instead,
544// as no session waits on it.
545type Report = (label: string, isWarning?: boolean) => Promise<void>
546
547async function seedDependencies($: $, options: PluginOptions, main: string, path: string, report: Report) {
548 const hash = await lockHash($, path)
549 if (hash !== '') {
550 let donor: string | null = null
551 const repoDir = await slotsRoot($, options, main)
552 for (const sibling of await slotDirs($, repoDir)) {
553 if (sibling === path || (await isTaken($, sibling)) || (await isWarmingLive($, await readWarming($, sibling)))) continue
554 if ((await $.fs.exists(`${sibling}/node_modules`)) && (await lockHash($, sibling)) === hash) {
555 donor = sibling
556 break
557 }
558 }
559 if (donor === null && (await $.fs.exists(`${main}/node_modules`)) && (await lockHash($, main)) === hash) donor = main
560 if (donor !== null) {
561 const missing: string[] = []
562 for (const relative of await nodeModulesIn($, donor)) {
563 if (!(await $.fs.exists(`${path}/${relative}`))) missing.push(relative)
564 }
565 if (missing.length > 0) {
566 await report(`Cloning ${missing.length === 1 ? 'node_modules' : `${missing.length} node_modules folders`} from ${donor === main ? 'the main checkout' : `slot ${donor.split('/').pop()}`}`)
567 for (const [index, relative] of missing.entries()) {
568 const target = `${path}/${relative}`
569 const staging = `${path}/.fish-seed-${index}`
570 await $.process.run(['mkdir', '-p', target.slice(0, target.lastIndexOf('/'))])
571 const copied = await $.process.run(['cp', '-c', '-p', '-R', `${donor}/${relative}`, staging], { timeoutMs: SETUP_TIMEOUT_MS })
572 if (copied.exitCode !== 0) {
573 $.ui.log(`cp -c failed: ${copied.stderr.trim()}`, { to: 'debug' })
574 await $.process.run(['rm', '-rf', staging])
575 await report('Could not clone node_modules; the setup command has to install them', true)
576 break
577 }
578 await $.process.run(['mv', staging, target])
579 }
580 }
581 }
582 }
583
584 const setup = String(options.setupCommand ?? '').trim()
585 if (setup === '') return
586 const last = await $.store.get(SETUP_PREFIX + path)
587 if (last !== null && typeof last === 'object' && (last as SetupRun).command === setup && (last as SetupRun).lockHash === hash) {
588 await report('Setup already done for these lockfiles')
589 return
590 }
591 await report(`Running ${setup}`)
592 const ran = await $.process.run(['/bin/bash', '-lc', setup], { cwd: path, timeoutMs: SETUP_TIMEOUT_MS })
593 if (ran.exitCode !== 0) {
594 $.ui.log(`setup command failed: ${ran.stderr.trim()}`, { to: 'debug' })
595 throw new StepError(`The setup command exited with ${ran.exitCode}.`)
596 }
597 await $.store.set(SETUP_PREFIX + path, { command: setup, lockHash: hash })
598}
599
600// Free disk where the slots live, in kilobytes; Infinity when df cannot say.
601async function freeKb($: $, dir: string): Promise<number> {
602 const df = await $.process.run(['df', '-Pk', dir])
603 const columns = df.stdout.trim().split('\n').pop()?.split(/\s+/) ?? []
604 const available = Number(columns[3])
605 return df.exitCode === 0 && Number.isFinite(available) ? available : Infinity
606}
607
608// What the pool holds now: its slots, made or being made by a live run, how
609// many of those no session holds, and the slots this session reserved that
610// wait to be made.
611async function poolNow($: $, main: string, repoDir: string): Promise<{ total: number; idle: number; queued: string[] }> {
612 const listed = await worktreePaths($, main)
613 const sessionId = await $.session.id()
614 let total = 0
615 let idle = 0
616 const queued: string[] = []
617 for (const path of await slotDirs($, repoDir)) {
618 const mark = await readWarming($, path)
619 const isListed = listed.has(await realPath($, path))
620 if (!isListed && mark?.sessionId === sessionId && mark.step === QUEUED) queued.push(path)
621 if (!isListed && !(await isWarmingLive($, mark))) continue
622 total++
623 if (!(await isTaken($, path))) idle++
624 }
625 return { total, idle, queued }
626}
627
628// The next slot number, reserved under the lock by its empty folder and
629// marked Queued, so every session lists it before anything is made there.
630async function reserveSlot($: $, repoDir: string): Promise<string> {
631 await lock($, repoDir)
632 try {
633 const dirs = await slotDirs($, repoDir)
634 const path = `${repoDir}/${dirs.reduce((most, one) => Math.max(most, Number(one.split('/').pop())), 0) + 1}`
635 await $.process.run(['mkdir', '-p', path])
636 await markWarming($, path, QUEUED)
637 return path
638 } finally {
639 await unlock($, repoDir)
640 }
641}
642
643// Where desktop-worktree.sh finds the pool, the switch it reads (there while
644// Use worktrees is on) and the script itself, beside the repository's git
645// data: a path that stays put as the plugin updates, which the settings name.
646// Then the settings are wired to it.
647async function installDesktopHook($: $, main: string, repoDir: string): Promise<void> {
648 try {
649 const common = await gitOut($, ['rev-parse', '--path-format=absolute', '--git-common-dir'], main, 'Could not find the git data.')
650 const keep = async (name: string, text: string) => {
651 const path = `${common}/fishstack/${name}`
652 if ((await $.fs.read(path).catch(() => null)) !== text) await $.fs.write(path, text)
653 }
654 await keep('slots-root', `${repoDir}\n`)
655 await keep('on', 'Use worktrees is on\n')
656 await keep('desktop-worktree.sh', String(await $.fs.read(`${$.plugin.root}/hooks/desktop-worktree.sh`)))
657 await wireDesktopHook($, main, true)
658 } catch (error) {
659 $.ui.log(`desktop hook not installed: ${String(error)}`, { to: 'debug' })
660 }
661}
662
663// Use worktrees off: the hook hands out no more slots, and the settings go
664// back to Claude Code's own worktrees.
665async function unwireDesktopHook($: $, main: string): Promise<void> {
666 try {
667 const common = await gitOut($, ['rev-parse', '--path-format=absolute', '--git-common-dir'], main, 'Could not find the git data.')
668 await $.process.run(['rm', '-f', `${common}/fishstack/on`])
669 await wireDesktopHook($, main, false)
670 } catch (error) {
671 $.ui.log(`desktop hook not removed: ${String(error)}`, { to: 'debug' })
672 }
673}
674
675// The settings that send worktree creation to desktop-worktree.sh: the
676// repository's .claude/settings.local.json, the person's own, kept out of
677// git. fishStack's two entries go in while Use worktrees is on and come out
678// when it goes off; the rest stays. A file that does not parse, or a
679// WorktreeCreate hook of the person's own, is left alone. Every client
680// there reads these settings; the script hands out slots only to the
681// desktop app with fishStack on, and makes any other client the worktree
682// Claude Code would.
683const HOOK_SCRIPT = 'fishstack/desktop-worktree.sh'
684const LOCAL_SETTINGS = '.claude/settings.local.json'
685
686function hookCommand(mode: 'create' | 'remove'): string {
687 return `bash "$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --path-format=absolute --git-common-dir)/${HOOK_SCRIPT}" ${mode}`
688}
689
690async function wireDesktopHook($: $, main: string, isOn: boolean): Promise<void> {
691 const path = `${main}/${LOCAL_SETTINGS}`
692 const isThere = await $.fs.exists(path)
693 if (!isThere && !isOn) return
694 let settings: Record<string, unknown> = {}
695 if (isThere) {
696 try {
697 const parsed: unknown = JSON.parse(String(await $.fs.read(path)))
698 if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return
699 settings = parsed as Record<string, unknown>
700 } catch {
701 return
702 }
703 }
704 const was = settings.hooks
705 const hooks: Record<string, unknown> = was !== null && typeof was === 'object' && !Array.isArray(was) ? { ...(was as Record<string, unknown>) } : {}
706 const isOurs = (group: unknown) => JSON.stringify(group).includes(HOOK_SCRIPT)
707 const groupsOf = (event: string) => (Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : [])
708 if (isOn && groupsOf('WorktreeCreate').some(group => !isOurs(group))) return
709 for (const [event, mode] of [['WorktreeCreate', 'create'], ['WorktreeRemove', 'remove']] as const) {
710 const groups = [...groupsOf(event).filter(group => !isOurs(group)), ...(isOn ? [{ hooks: [{ type: 'command', command: hookCommand(mode) }] }] : [])]
711 if (groups.length > 0) hooks[event] = groups
712 else delete hooks[event]
713 }
714 const { hooks: _hooks, ...rest } = settings
715 const next = Object.keys(hooks).length > 0 ? { ...rest, hooks } : rest
716 if (JSON.stringify(next) === JSON.stringify(settings)) return
717 if (isOn) await keepOutOfGit($, main, LOCAL_SETTINGS)
718 await $.fs.write(path, `${JSON.stringify(next, null, 2)}\n`)
719 if (isOn) await $.ui.toast(`fishStack: desktop sessions in ${main.split('/').pop()} now start in a pool slot (${LOCAL_SETTINGS}, kept out of git).`)
720}
721
722// Keeps one slot offered to the desktop's WorktreeCreate hook, marked by its
723// .ready file, so a desktop session starts in it with no checkout. Only a
724// slot no one uses is offered: detached and clean, as a new slot or one a
725// claim scrubbed is. One a session left its branch in stays its own.
726async function offerSlot($: $, main: string, repoDir: string): Promise<void> {
727 await lock($, repoDir)
728 try {
729 const listed = await worktreePaths($, main)
730 let free: string | null = null
731 for (const path of await slotDirs($, repoDir)) {
732 if (await $.fs.exists(path + READY)) return
733 const real = await realPath($, path)
734 if (free !== null || !listed.has(real) || listed.get(real) !== null) continue
735 if ((await isWarmingLive($, await readWarming($, path))) || (await isTaken($, path))) continue
736 const status = await git($, ['status', '--porcelain'], path)
737 if (status.exitCode === 0 && status.stdout.trim() === '') free = path
738 }
739 if (free === null) return
740 await $.fs.write(free + READY, String(await $.clock.now()))
741 await signalPool($, free)
742 } finally {
743 await unlock($, repoDir)
744 }
745}
746
747// Keeps the pool warm, as CommissionAI does: at least its size in slots
748// (POOL_SIZE, or what + Add slot and Remove made it), at least one of them
749// idle, each made, seeded and set up ahead of any session, one at a time in
750// the background. Each slot is listed as setting up from the moment it is
751// reserved. Pauses below minFreeGb of free disk, and says so in the tab.
752let isWarming = false
753// A top-up asked for while one runs: run once more when it ends, as a claim
754// made meanwhile may have taken the spare it was counting on.
755let isWarmAgain = false
756async function prewarm($: $, options: PluginOptions, main: string): Promise<void> {
757 if (isWarming) {
758 isWarmAgain = true
759 return
760 }
761 isWarming = true
762 let path: string | null = null
763 try {
764 const repoDir = await slotsRoot($, options, main)
765 const sessionId = await $.session.id()
766 // Marks this session left mid-step belong to a run that is gone (none
767 // runs here now); Queued ones still wait to be made.
768 for (const one of await slotDirs($, repoDir)) {
769 const mark = await readWarming($, one)
770 if (mark?.sessionId === sessionId && mark.step !== QUEUED) await $.store.delete(WARMING_PREFIX + one)
771 }
772 await update($, poolNote, () => '')
773 const branchName = await defaultBranch($, main)
774 const minFreeGb = Number(options.minFreeGb ?? DEFAULT_MIN_FREE_GB)
775 let sha: string | null = null
776 let note = ''
777 for (let made = 0; made < Math.max(await poolSize($, repoDir), 1); made++) {
778 const pool = await poolNow($, main, repoDir)
779 path = pool.queued[0] ?? null
780 if (path === null && pool.total >= (await poolSize($, repoDir)) && pool.idle >= 1) break
781 await $.process.run(['mkdir', '-p', repoDir])
782 const free = await freeKb($, repoDir)
783 if (free < minFreeGb * 1024 * 1024) {
784 note = `Paused: ${(free / 1024 / 1024).toFixed(1)} GB of disk free, under the ${minFreeGb} GB it keeps.`
785 path = null
786 break
787 }
788 if (path !== null) await markWarming($, path, `Fetching origin/${branchName}`)
789 if (sha === null) {
790 const isFetched = await fetchBase($, main, branchName)
791 sha = await gitOut($, ['rev-parse', isFetched ? `origin/${branchName}` : branchName], main, 'Could not resolve the base.')
792 }
793 if (path === null) {
794 path = await reserveSlot($, repoDir)
795 await refreshSlotsFor($, options, main)
796 }
797 const at = path
798 await markWarming($, at, 'Checking out')
799 await createSlot($, main, at, sha)
800 await seedDependencies($, options, main, at, label => markWarming($, at, label))
801 await $.store.delete(WARMING_PREFIX + at)
802 await signalPool($, at)
803 path = null
804 await refreshSlotsFor($, options, main)
805 }
806 await installDesktopHook($, main, repoDir)
807 await offerSlot($, main, repoDir)
808 await update($, poolNote, () => note)
809 } catch (error) {
810 $.ui.log(`prewarm stopped: ${String(error)}`, { to: 'debug' })
811 // The slot it was making is no longer listed as setting up.
812 if (path !== null) {
813 await $.store.delete(WARMING_PREFIX + path)
814 await signalPool($, path)
815 }
816 await refreshSlotsFor($, options, main).catch(() => undefined)
817 await update($, poolNote, () => (error instanceof StepError ? `Warming stopped: ${error.message}` : 'Warming stopped.'))
818 } finally {
819 isWarming = false
820 if (isWarmAgain) {
821 isWarmAgain = false
822 $.clock.after(0, () => void prewarm($, options, main))
823 }
824 }
825}
826
827export async function defaultBranch($: $, main: string): Promise<string> {
828 const head = await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], main)
829 if (head.exitCode === 0) return head.stdout.trim().replace(/^origin\//, '')
830 return gitOut($, ['rev-parse', '--abbrev-ref', 'HEAD'], main, 'Could not read the current branch.')
831}
832
833// Fetches origin's branch, true when it came: one fetch at a time per
834// repository in this process, shared by whoever asks meanwhile. A session's
835// claim and the pool's warming start together, and of two fetches into one
836// repository one loses the ref lock and starts from a stale base. A fetch
837// still running at its timeout (a checkout far behind) did not come either.
838const fetching = new Map<string, Promise<boolean>>()
839
840async function fetchBase($: $, main: string, branchName: string): Promise<boolean> {
841 const key = `${main}\n${branchName}`
842 let run = fetching.get(key)
843 if (run === undefined) {
844 run = git($, ['fetch', '-q', 'origin', branchName], main)
845 .then(ran => ran.exitCode === 0, () => false)
846 .finally(() => fetching.delete(key))
847 fetching.set(key, run)
848 }
849 return run
850}
851
852// Held by the module, not $.state: a reload runs session.start again, which
853// restarts prepare() and its heartbeat.
854let heartbeat: Timer | null = null
855
856function startHeartbeat($: $, path: string): void {
857 heartbeat?.cancel()
858 heartbeat = $.clock.every(HEARTBEAT_MS, () => void writeLease($, path))
859}
860
861async function fail($: $, error: unknown): Promise<void> {
862 const message = error instanceof StepError ? error.message : 'Something went wrong while preparing the worktree.'
863 if (!(error instanceof StepError)) $.ui.log(`prepare failed: ${String(error)}`, { to: 'debug' })
864 await update($, worktree, (current): Worktree => ({ ...current, phase: 'failed', step: message, error: message }))
865}
866
867async function markReady($: $): Promise<void> {
868 await setStep($, 'Worktree ready')
869 await update($, worktree, (current): Worktree => ({ ...current, phase: 'ready', error: null }))
870}
871
872async function enter($: $, options: PluginOptions): Promise<void> {
873 const { path, branch } = await read($, worktree)
874 if (path === null || branch === null) return
875 // Claude Code may ask the person to confirm the move: the stepper says
876 // where things stand meanwhile.
877 await setStep($, 'Moving the session in')
878 const entered = await $.tool.call({ tool: 'EnterWorktree', path })
879 if (entered.deny !== undefined || entered.isError === true) {
880 $.ui.log(`EnterWorktree refused: ${entered.deny ?? entered.text ?? ''}`, { to: 'debug' })
881 await fail($, new StepError('Claude Code would not move the session into the worktree.'))
882 return
883 }
884 await settle($, options, path, branch)
885}
886
887// Ready: the lock lifts, and a prompt that came in during the head start is
888// told to carry on.
889async function settle($: $, options: PluginOptions, path: string, branch: string | null): Promise<void> {
890 await markReady($)
891 await refreshSlots($, options)
892 if (await read($, isHeadStartPrompted)) {
893 await update($, isHeadStartPrompted, () => false)
894 await $.prompt.submit({ text: continueNote(path, branch ?? 'HEAD') })
895 } else if (await read($, isTurnRunning)) {
896 // Moved under a turn that never heard of the head start (it began before
897 // the claim did): told at its next batch or prompt. Before any turn, the
898 // session simply starts where it works.
899 await update($, owedMoveNote, () => movedNote(path, branch ?? 'HEAD'))
900 }
901}
902
903async function prepare($: $, options: PluginOptions): Promise<void> {
904 const { main, path: given } = await read($, worktree)
905 if (main === null) return
906 try {
907 // The desktop's own worktree: seeded and set up where it is, no move.
908 if (given !== null && !(await isSlot($, options, main, given))) {
909 await setStep($, 'Setting up this worktree in place')
910 await seedDependencies($, options, main, given, (label, isWarning) => setStep($, label, isWarning))
911 await settle($, options, given, (await read($, worktree)).branch)
912 return
913 }
914 const branchName = await defaultBranch($, main)
915 await setStep($, `Fetching the latest origin/${branchName}`)
916 let base = `origin/${branchName}`
917 if (!(await fetchBase($, main, branchName))) {
918 base = branchName
919 await setStep($, `Could not fetch origin; starting from the local ${branchName}`, true)
920 }
921 const baseSha = await gitOut($, ['rev-parse', base], main, `Could not resolve ${base}.`)
922 const wanted = `fish/${adoptedName ?? (await $.session.id()).slice(0, 6)}`
923 await update($, worktree, current => ({ ...current, base, baseSha }))
924
925 await setStep($, 'Claiming a slot')
926 const { path, branch } = await claimSlot($, options, main, base, baseSha, wanted)
927 await update($, worktree, current => ({ ...current, path, branch }))
928 startHeartbeat($, path)
929 await seedDependencies($, options, main, path, (label, isWarning) => setStep($, label, isWarning))
930 await refreshSlots($, options)
931 $.clock.after(0, () => void prewarm($, options, main))
932
933 await enter($, options)
934 } catch (error) {
935 await fail($, error)
936 }
937}
938
939async function beginWorktree($: $, options: PluginOptions): Promise<void> {
940 await update($, worktree, (current): Worktree => ({ ...current, phase: 'preparing', step: 'Starting', steps: [], error: null }))
941 $.clock.after(0, () => void prepare($, options))
942}
943
944const PHASE: Record<WorktreePhase, { label: string; color: string }> = {
945 off: { label: 'Off', color: 'inactive' },
946 preparing: { label: 'Preparing (read-only)', color: 'warning' },
947 ready: { label: 'Ready', color: 'success' },
948 failed: { label: 'Failed (read-only)', color: 'error' },
949}
950
951// Claims a slot once per load, for the first person at the session: a REPL
952// at session.start, or the desktop app or an editor attaching (they run the
953// session headless, so its session.start says no one is at the prompt).
954let hasStarted = false
955// Where the session worked as a /clear ended it, for the session after.
956let carried: { worktree: Worktree; elsewhere: string | null } | null = null
957
958// After a /clear: the session that follows works where the last one did,
959// in the same slot, its lease kept alive from here.
960async function carryOverClear($: $, options: PluginOptions): Promise<void> {
961 if (carried === null) return
962 const { worktree: was, elsewhere: where } = carried
963 carried = null
964 await update($, worktree, () => was)
965 await update($, elsewhere, () => where)
966 if (was.phase === 'ready' && was.path !== null && was.main !== null && (await isSlot($, options, was.main, was.path))) startHeartbeat($, was.path)
967}
968
969export const AUTO_WORKTREE_KEY = 'autoWorktree'
970
971// + Add slot: the next slot reserved and listed at once, setting up, then
972// made in the background like the rest, behind the same disk guard. The
973// pool keeps one more from then on.
974async function addSlot($: $, options: PluginOptions): Promise<void> {
975 const main = (await read($, worktree)).main ?? (await repoMain($))
976 if (main === null) return
977 try {
978 const repoDir = await slotsRoot($, options, main)
979 await reserveSlot($, repoDir)
980 const { total } = await poolNow($, main, repoDir)
981 await $.store.set(POOL_SIZE_PREFIX + repoDir, Math.max(await poolSize($, repoDir), total))
982 await refreshSlotsFor($, options, main)
983 } catch (error) {
984 $.ui.log(`add slot failed: ${String(error)}`, { to: 'debug' })
985 await update($, poolNote, () => (error instanceof StepError ? `Could not add a slot: ${error.message}` : 'Could not add a slot.'))
986 return
987 }
988 await prewarm($, options, main)
989}
990
991// Remove: a free slot leaves the pool, and the pool keeps one fewer from
992// then on. It is leased under the lock so no session claims it meanwhile,
993// its leftovers are backed up, then it is moved out of the numbered slots
994// at once and deleted. Its branch stays.
995async function removeSlot($: $, options: PluginOptions, path: string): Promise<void> {
996 await update($, slotAsk, () => null)
997 const main = (await read($, worktree)).main ?? (await repoMain($))
998 if (main === null) return
999 const number = path.split('/').pop()
1000 removing.add(path)
1001 await update($, slots, list => list.map(one => (one.path === path ? { ...one, isRemoving: true } : one)))
1002 try {
1003 const repoDir = await slotsRoot($, options, main)
1004 await lock($, repoDir)
1005 let isFree = false
1006 try {
1007 isFree =
1008 path !== (await read($, worktree)).path &&
1009 !(await isTaken($, path)) &&
1010 !(await isWarmingLive($, await readWarming($, path)))
1011 if (isFree) await writeLease($, path)
1012 } finally {
1013 await unlock($, repoDir)
1014 }
1015 if (!isFree) {
1016 await $.ui.toast(`Slot ${number} is in use now, so it stays.`)
1017 return
1018 }
1019 if ((await worktreePaths($, main)).has(await realPath($, path))) await backUpLeftovers($, path)
1020 const gone = `${repoDir}/.removing-${number}-${await $.clock.now()}`
1021 const moved = await $.process.run(['mv', path, gone])
1022 if (moved.exitCode !== 0) throw new StepError(`Could not move slot ${number} out of the pool.`)
1023 for (const prefix of [LEASE_PREFIX, SETUP_PREFIX, WARMING_PREFIX]) await $.store.delete(prefix + path)
1024 await $.process.run(['rm', '-f', path + READY])
1025 await $.process.run(['rm', '-rf', gone], { timeoutMs: SETUP_TIMEOUT_MS })
1026 await git($, ['worktree', 'prune'], main)
1027 await signalPool($, path)
1028 const { total } = await poolNow($, main, repoDir)
1029 await $.store.set(POOL_SIZE_PREFIX + repoDir, Math.min(await poolSize($, repoDir), total))
1030 } catch (error) {
1031 $.ui.log(`remove slot failed: ${String(error)}`, { to: 'debug' })
1032 await releaseSlot($, path)
1033 await $.ui.toast(error instanceof StepError ? error.message : `Could not remove slot ${number}.`)
1034 } finally {
1035 removing.delete(path)
1036 await refreshSlotsFor($, options, main)
1037 }
1038}
1039
1040// The Worktrees tab's toggle, kept across sessions for each repository;
1041// until it is pressed, the autoWorktree option decides, off unless set.
1042async function isAutoOn($: $, options: PluginOptions): Promise<boolean> {
1043 const stored = await getSetting($, AUTO_WORKTREE_KEY)
1044 return typeof stored === 'boolean' ? stored : options.autoWorktree === true
1045}
1046
1047async function setAuto($: $, options: PluginOptions, isOn: boolean): Promise<void> {
1048 await setSetting($, AUTO_WORKTREE_KEY, isOn)
1049 $.ui.invalidate('ui.render')
1050 const main = (await read($, worktree)).main ?? (await repoMain($))
1051 if (main === null) return
1052 if (isOn) $.clock.after(0, () => void prewarm($, options, main))
1053 else await unwireDesktopHook($, main)
1054}
1055
1056async function startWorktree($: $, options: PluginOptions, cwd: string, { isForced = false, isDesktop = false } = {}): Promise<void> {
1057 if (hasStarted) return
1058 hasStarted = true
1059 const current = await read($, worktree)
1060 // On, the pool is checked at every load: a reload stops a warming run
1061 // midway, and a pool left short by other sessions tops up here.
1062 if (await isAutoOn($, options)) {
1063 const poolMain = current.main ?? (await repoMain($))
1064 if (poolMain !== null) $.clock.after(0, () => void prewarm($, options, poolMain))
1065 }
1066 if (current.phase === 'ready' && current.path !== null) {
1067 if (current.main !== null && (await isSlot($, options, current.main, current.path))) startHeartbeat($, current.path)
1068 return
1069 }
1070 if (current.phase === 'preparing') {
1071 await beginWorktree($, options)
1072 return
1073 }
1074 const repo = await $.session.repo()
1075 if (repo === null) return
1076 const top = await git($, ['rev-parse', '--show-toplevel'], cwd)
1077 if (top.exitCode !== 0) return
1078 const here = await realPath($, top.stdout.trim())
1079 // The main checkout is where the repository's .git lives; a worktree's
1080 // top level is not it.
1081 const common = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)
1082 const commonDir = common.stdout.trim()
1083 const main = common.exitCode === 0 && commonDir.endsWith('/.git') ? await realPath($, commonDir.slice(0, -'/.git'.length)) : here
1084
1085 // Already in one of our slots (a session carried on there, or started
1086 // there): it is this session's, ready, whatever the toggle says.
1087 if (here !== main) {
1088 const repoDir = await slotsRoot($, options, main)
1089 const realDir = await realPath($, repoDir).catch(() => repoDir)
1090 const inside = [realDir, repoDir].find(dir => here.startsWith(`${dir}/`))
1091 if (inside !== undefined) {
1092 const path = `${repoDir}${here.slice(inside.length)}`
1093 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], here)).stdout.trim() || null
1094 const at = await $.clock.now()
1095 await update($, worktree, (): Worktree => ({ ...IDLE_WORKTREE, phase: 'ready', main, path, branch, step: 'Carried on in this slot', steps: [{ label: 'Carried on in this slot', at, isWarning: false }] }))
1096 await writeLease($, path)
1097 await signalPool($, path)
1098 startHeartbeat($, path)
1099 await refreshSlots($, options)
1100 return
1101 }
1102 }
1103
1104 // Off, fishStack stays out of the way: no slot, no read-only guard, and
1105 // Claude Code's own worktree handling applies.
1106 if (!isForced && !(await isAutoOn($, options))) {
1107 if (here !== main) await update($, elsewhere, () => here)
1108 return
1109 }
1110
1111 if (here !== main) {
1112 // A worktree Claude Code made itself (claude --worktree, the desktop's
1113 // worktree option): those are made before any mod loads, so its
1114 // WorktreeCreate never reaches fishStack. Any other worktree (a slot of
1115 // ours among them) is left as it is. Work already begun there (a reload
1116 // mid-session runs this again) stays with it.
1117 if (!here.startsWith(`${main}/.claude/worktrees/`) || !(await isUntouched($, here))) {
1118 await update($, elsewhere, () => here)
1119 return
1120 }
1121 // The desktop app's: set up where it is, read-only until then. The app's
1122 // diff, PR and archive follow the worktree it made, and it reuses its
1123 // worktrees as sessions end, so a slot would only duplicate it.
1124 if (isDesktop) {
1125 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], here)).stdout.trim() || null
1126 await update($, worktree, (): Worktree => ({ ...IDLE_WORKTREE, main, path: here, branch }))
1127 await beginWorktree($, options)
1128 return
1129 }
1130 // From the terminal: adopted into a slot on its name, read-only until
1131 // the slot is ready, then the session moves in. A slot starts from origin.
1132 adoptedName = here.split('/').pop()!.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)
1133 } else if (main !== (await realPath($, repo.root))) {
1134 return
1135 } else if (isDesktop && !isForced) {
1136 // The desktop's composer asks whether a session gets a worktree, and
1137 // makes one itself when it does (adopted above). In the main checkout,
1138 // the person asked for none: it stays there.
1139 return
1140 } else if (!isForced && (await terminalChoice($)) === 'ask') {
1141 // From the terminal or an editor, plain `claude` in the main checkout
1142 // says nothing about a worktree, so the person is asked, once a session,
1143 // unless they said always. The pool warms meanwhile, so a worktree is
1144 // still quick to take.
1145 if (await read($, isWhereAsked)) return
1146 await update($, isWhereAsked, () => true)
1147 $.clock.after(0, () => void askWhere($, options, main))
1148 return
1149 }
1150
1151 await update($, worktree, now => ({ ...now, main }))
1152 await beginWorktree($, options)
1153}
1154
1155const WHERE_QUESTION = 'Where should this session work?'
1156const WHERE_WORKTREE = 'A worktree'
1157const WHERE_MAIN = 'This main checkout'
1158const WHERE_ALWAYS = 'Always a worktree'
1159
1160// What a new terminal session in a main checkout does with Use worktrees on:
1161// asks, or takes a worktree as the person said it always should. Kept across
1162// sessions for each repository, like the toggle.
1163export const TERMINAL_KEY = 'terminalWorktree'
1164type TerminalChoice = 'ask' | 'always'
1165const TERMINAL_CHOICES = [
1166 { value: 'ask', label: 'Ask each time' },
1167 { value: 'always', label: 'Always a worktree' },
1168] as const
1169
1170async function terminalChoice($: $): Promise<TerminalChoice> {
1171 return (await getSetting($, TERMINAL_KEY)) === 'always' ? 'always' : 'ask'
1172}
1173
1174async function setTerminalChoice($: $, choice: TerminalChoice): Promise<void> {
1175 await setSetting($, TERMINAL_KEY, choice)
1176 $.ui.invalidate('ui.render')
1177}
1178
1179// What each answer to the where question leads to, the main checkout's
1180// branch among it: the dialog draws them under the labels (see the
1181// AskUserQuestion render below), as $.ui.ask carries labels alone.
1182let whereDetails: Record<string, string> | null = null
1183
1184// Off the session's start, so its dialog never holds the start up. A
1185// worktree, now or always, claims one; the main checkout, a dismissed
1186// dialog or a typed answer leave the session where it is, with Create
1187// worktree now in the tab. A plugin's question, so Autopilot leaves it to
1188// the person.
1189async function askWhere($: $, options: PluginOptions, main: string): Promise<void> {
1190 let answer: string
1191 try {
1192 const on = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], main)).stdout.trim()
1193 const base = await defaultBranch($, main).catch(() => 'the default branch')
1194 whereDetails = {
1195 [WHERE_WORKTREE]: `A branch of its own from the latest origin/${base}. It reads and plans at once and edits once it moves in, in seconds.`,
1196 [WHERE_MAIN]: `Right here, ${on === '' || on === 'HEAD' ? 'on a detached HEAD' : `on ${on}`}, with full access. Nothing is claimed.`,
1197 [WHERE_ALWAYS]: 'A worktree now, and for every new terminal session. Back to asking from /fs.',
1198 }
1199 answer = await $.ui.ask(WHERE_QUESTION, { header: 'Worktree', options: [WHERE_WORKTREE, WHERE_MAIN, WHERE_ALWAYS] })
1200 } catch {hooks/art.ts 70 lines1// The pane's drawn controls for the desktop: the tab bar, switches and preset
2// pills, as SVG that follows light and dark. Presses reach them through a
3// Client laid over each (see drawnButton in kit.tsx, and the tab bar's own
4// overlay in ui.tsx).
5import { brandMark } from './brand'
6
7const SANS = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif`
8const ROUNDED = `ui-rounded, 'SF Pro Rounded', -apple-system, system-ui, sans-serif`
9
10export const THEME = `<style>
11 svg { --text: #ecebe8; --dim: #9a9aa2; --track: rgba(154,154,162,0.32); --pill: rgba(255,255,255,0.045); --pillLine: rgba(255,255,255,0.10); --active: rgba(255,255,255,0.13); --activeLine: rgba(255,255,255,0.22); --knob: #ffffff; }
12 @media (prefers-color-scheme: light) { svg { --text: #1f1f22; --dim: #6b6b72; --track: rgba(107,107,114,0.28); --pill: rgba(0,0,0,0.035); --pillLine: rgba(0,0,0,0.10); --active: #ffffff; --activeLine: rgba(0,0,0,0.14); --knob: #ffffff; } }
13 .num { font-variant-numeric: tabular-nums; }
14</style>`
15
16// Theme colours as style declarations, which every renderer reads: one style
17// per element, since a second style attribute is invalid XML and the image
18// fails to load.
19export function paint(markup: string): string {
20 return markup.replace(/<[a-z]+\b[^>]*>/g, tag => {
21 const styles: string[] = []
22 const bare = tag.replace(/\s(fill|stroke)="var\((--[A-Za-z]+)\)"/g, (_, property: string, name: string) => {
23 styles.push(`${property}: var(${name})`)
24 return ''
25 })
26 if (styles.length === 0) return tag
27 return bare.replace(/(\/?>)$/, ` style="${styles.join('; ')}"$1`)
28 })
29}
30
31export function textWidth(text: string, size: number, isBold = false): number {
32 let units = 0
33 for (const char of text) units += /[mwMW@]/.test(char) ? 0.86 : /[il.:,'|!1]/.test(char) ? 0.3 : /[A-Z0-9$]/.test(char) ? 0.64 : 0.54
34 return units * size * (isBold ? 1.0 : 0.95)
35}
36
37// The mark and the name, as the pane's head shows them: a still picture.
38export function wordmarkArt(): { source: string; width: number; height: number } {
39 const height = 32
40 const mid = height / 2
41 const width = Math.ceil(30 + textWidth('fishStack', 15, true) + 4)
42 const defs = `<defs><linearGradient id="brand" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#5eead4"/><stop offset="1" stop-color="#3b82f6"/></linearGradient></defs>`
43 const parts = brandMark(1, mid - 11, 22, 'paneMark') + `<text x="30" y="${mid + 5}" font-family="${ROUNDED}" font-size="15" font-weight="750" fill="url(#brand)" letter-spacing="0.1">fishStack</text>`
44 return { width, height, source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${THEME}${defs}${paint(parts)}</svg>` }
45}
46
47// An iOS-style switch: green and right when on, grey and left when off, amber
48// with the knob centred when some of a folder is on.
49export function switchArt(state: 'on' | 'off' | 'some'): string {
50 const track = state === 'on' ? '#34c759' : state === 'some' ? '#d6a24a' : 'var(--track)'
51 const knob = state === 'on' ? 25 : state === 'some' ? 18 : 11
52 return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="22" viewBox="0 0 36 22">${THEME}${paint(`<rect x="1" y="1" width="34" height="20" rx="10" fill="${track}"/><circle cx="${knob}" cy="11" r="8.2" fill="var(--knob)" stroke="rgba(0,0,0,0.12)"/>`)}</svg>`
53}
54
55// A preset as CommissionAI draws one: its radar glyph inside a pill, the
56// active one outlined in the accent.
57export function presetArt(name: string, levels: number[], isActive: boolean): { source: string; width: number; height: number } {
58 const height = 32
59 const width = Math.ceil(12 + 17 + 8 + textWidth(name, 13, isActive) + 14)
60 const point = (index: number, level: number) => {
61 const angle = ((-90 + (index * 360) / levels.length) * Math.PI) / 180
62 return `${(8.5 + Math.cos(angle) * 7.8 * (level / 5)).toFixed(2)},${(8.5 + Math.sin(angle) * 7.8 * (level / 5)).toFixed(2)}`
63 }
64 const accent = '#8f9cff'
65 const glyph = `<g transform="translate(12 ${height / 2 - 8.5})"><polygon points="${levels.map((_, i) => point(i, 5)).join(' ')}" fill="none" stroke="${isActive ? accent : 'var(--dim)'}" stroke-opacity="0.45"/><polygon points="${levels.map((level, i) => point(i, level)).join(' ')}" fill="${isActive ? accent : 'var(--dim)'}" fill-opacity="0.32" stroke="${isActive ? accent : 'var(--dim)'}" stroke-width="1.2" stroke-linejoin="round"/></g>`
66 const pill = `<rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="${(height - 1.5) / 2}" fill="${isActive ? 'rgba(143,156,255,0.16)' : 'var(--pill)'}" stroke="${isActive ? accent : 'var(--pillLine)'}" stroke-width="${isActive ? 1.5 : 1}"/>`
67 const text = `<text x="${12 + 17 + 8}" y="${height / 2 + 4.6}" font-family="${SANS}" font-size="13" font-weight="${isActive ? 650 : 500}" fill="${isActive ? 'var(--text)' : 'var(--dim)'}">${name}</text>`
68 return { width, height, source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${THEME}${paint(pill + glyph + text)}</svg>` }
69}
70hooks/kit.tsx 347 lines1// The pane's small design kit, after CommissionAI's Stage: cards that light
2// under the pointer, section labels, a segmented control, chips and the SVG
3// glyphs the desktop draws. A terminal gets the same tree with borders and
4// text in place of fills and pictures.
5import type { RenderElement } from 'claude-code'
6
7import { switchArt } from './art'
8import type { Ui } from './ui'
9
10// Fills read on dark and light themes alike; the terminal never gets them.
11const FILL = 'rgba(140,140,150,0.07)'
12const FILL_HOVER = 'rgba(140,140,150,0.15)'
13const FILL_SELECTED = 'rgba(120,140,255,0.12)'
14
15export type Tone = 'plain' | 'selected' | 'muted' | 'accent' | 'warning' | 'danger'
16
17const BORDER: Record<Tone, string> = {
18 plain: 'subtle',
19 selected: 'suggestion',
20 muted: 'subtle',
21 accent: 'claude',
22 warning: 'warning',
23 danger: 'error',
24}
25
26export function isRich(ui: Ui): boolean {
27 return ui.Svg !== undefined
28}
29
30// A card: a keyed Box, filled and lit under the pointer on the desktop,
31// bordered everywhere.
32export function card(
33 ui: Ui,
34 key: string,
35 children: RenderElement | (RenderElement | false | null | undefined)[],
36 options: { tone?: Tone; width?: number | string; direction?: 'row' | 'column'; grow?: number } = {},
37) {
38 const { Box } = ui
39 const tone = options.tone ?? 'plain'
40 const rich = isRich(ui)
41 return (
42 <Box
43 key={key}
44 flexDirection={options.direction ?? 'column'}
45 width={options.width}
46 flexGrow={options.grow}
47 borderStyle="round"
48 borderColor={BORDER[tone]}
49 borderDimColor={tone === 'plain' || tone === 'muted'}
50 backgroundColor={rich ? (tone === 'selected' ? FILL_SELECTED : FILL) : undefined}
51 hover={rich ? { backgroundColor: tone === 'selected' ? FILL_SELECTED : FILL_HOVER, borderColor: tone === 'muted' || tone === 'plain' ? 'suggestion' : BORDER[tone] } : undefined}
52 paddingX={1}
53 paddingY={rich ? 1 : 0}
54 >
55 {children}
56 </Box>
57 )
58}
59
60export function label(ui: Ui, text: string) {
61 const { Text } = ui
62 return (
63 <Text dimColor bold>
64 {text}
65 </Text>
66 )
67}
68
69// CommissionAI's segmented control: the chosen option drawn primary.
70export function segmented<T extends string>(
71 ui: Ui,
72 key: string,
73 options: readonly { value: T; label: string }[],
74 value: T | 'mixed' | null,
75 onPick: (value: T) => void,
76) {
77 const { Box, Button, Text } = ui
78 return (
79 <Box gap={1} alignItems="center" flexWrap="wrap">
80 {options.map(one => (
81 <Button
82 key={`${key}-${one.value}`}
83 label={one.label}
84 variant={value === one.value ? 'primary' : undefined}
85 dimColor={value !== one.value}
86 onPress={() => onPick(one.value)}
87 />
88 ))}
89 {value === 'mixed' && chip(ui, 'mixed', 'warning')}
90 </Box>
91 )
92}
93
94// A short coloured tag: a filled badge on the desktop, bracketed on the terminal.
95export function chip(ui: Ui, text: string, color: string) {
96 const { Text } = ui
97 return isRich(ui) ? (
98 <Text backgroundColor={color} color="inverseText" bold>
99 {`\u00a0\u00a0${text}\u00a0\u00a0`}
100 </Text>
101 ) : (
102 <Text color={color} bold>
103 [{text}]
104 </Text>
105 )
106}
107
108export function svg(ui: Ui, source: string, alt: string, width: number, height: number) {
109 const { Svg } = ui
110 return Svg === undefined ? null : <Svg source={source} alt={alt} width={width} height={height} />
111}
112
113const FONT = 'font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif"'
114
115function escape(text: string): string {
116 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
117}
118
119export type Glyph = 'folder' | 'note' | 'link' | 'file' | 'plus'
120
121export const GLYPH_COLOR: Record<Glyph, string> = {
122 folder: '#d6a24a',
123 note: '#8fa8ff',
124 link: '#5cc8e8',
125 file: '#9ad48a',
126 plus: '#9aa1ab',
127}
128
129export function glyph(kind: Glyph, size = 18): string {
130 const color = GLYPH_COLOR[kind]
131 const shapes: Record<Glyph, string> = {
132 folder: `<path d="M2 5.5a1.5 1.5 0 0 1 1.5-1.5h3.6l1.6 1.8h5.8A1.5 1.5 0 0 1 16 7.3v6.2a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 2 13.5z" fill="${color}" fill-opacity="0.22" stroke="${color}" stroke-width="1.3"/>`,
133 note: `<rect x="4" y="2.5" width="10" height="13" rx="1.6" fill="${color}" fill-opacity="0.16" stroke="${color}" stroke-width="1.3"/><path d="M6.5 6.5h5M6.5 9h5M6.5 11.5h3" stroke="${color}" stroke-width="1.2" stroke-linecap="round"/>`,
134 link: `<path d="M7.6 10.4l2.8-2.8M6.4 8.4l-1.6 1.6a2.4 2.4 0 0 0 3.4 3.4l1.6-1.6M11.6 9.6l1.6-1.6a2.4 2.4 0 0 0-3.4-3.4L8.2 6.2" fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>`,
135 file: `<path d="M5 2.5h5.2L13.5 6v8a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 14V4A1.5 1.5 0 0 1 5 2.5z" fill="${color}" fill-opacity="0.16" stroke="${color}" stroke-width="1.3"/><path d="M10 2.5V6h3.5" fill="none" stroke="${color}" stroke-width="1.3"/>`,
136 plus: `<path d="M9 4v10M4 9h10" stroke="${color}" stroke-width="1.6" stroke-linecap="round"/>`,
137 }
138 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 18 18">${shapes[kind]}</svg>`
139}
140
141// CommissionAI's TickMeter: twenty ticks, teal within budget, red past it.
142export function tickMeter(fraction: number, width: number): string {
143 const count = 20
144 const gap = 3
145 const tick = Math.max(4, Math.floor((width - gap * (count - 1)) / count))
146 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * count)
147 const over = fraction > 1
148 const rects = Array.from({ length: count }, (_, index) => {
149 const fill = index < filled ? (over ? '#e5534b' : '#3fb6a8') : 'rgba(139,148,158,0.28)'
150 return `<rect x="${index * (tick + gap)}" y="0" width="${tick}" height="10" rx="2" fill="${fill}"/>`
151 }).join('')
152 const total = count * tick + (count - 1) * gap
153 return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="10" viewBox="0 0 ${total} 10">${rects}</svg>`
154}
155
156// A source's cover, as CommissionAI's cards show one: a page of the note's
157// first lines, a link's card with its host, or a file tab with its path.
158export function cover(kind: Exclude<Glyph, 'plus' | 'folder'>, text: string, caption: string, width: number, height: number, isOff: boolean): string {
159 const color = GLYPH_COLOR[kind]
160 const perLine = Math.max(20, Math.floor((width - 28) / 6.1))
161 const wrap = new RegExp(`.{1,${perLine}}(\\s|$)`, 'g')
162 const isProse = /[A-Za-z]{3,}\s+[A-Za-z]{2,}/.test(text) && !/^\s*[[{]/.test(text)
163 const all = (isProse ? text : '')
164 .split(/\n+/)
165 .flatMap(line => (line.match(wrap) ?? [line]).map(part => part.trim()))
166 .filter(line => line !== '')
167 const room = kind === 'link' ? 1 : 3
168 const lines = all.slice(0, room)
169 if (all.length > room && lines.length > 0) lines[lines.length - 1] = `${lines[lines.length - 1]!.replace(/[.,;:]?$/, '')}…`
170 if (lines.length === 0) lines.push(kind === 'note' ? 'Empty' : '⚠ No readable text fetched')
171 const textLines = lines
172 .map((line, index) => `<text x="14" y="${(kind === 'link' ? 50 : 30) + index * 15}" ${FONT} font-size="11" fill="#9aa1ab">${escape(line.slice(0, perLine + 4))}</text>`)
173 .join('')
174 const head =
175 kind === 'link'
176 ? `<rect x="10" y="10" width="${width - 20}" height="26" rx="6" fill="${color}" fill-opacity="0.14"/><text x="20" y="27" ${FONT} font-size="11.5" font-weight="600" fill="${color}">${escape(caption.slice(0, 34))}</text>`
177 : kind === 'file'
178 ? `<text x="14" y="17" ${FONT} font-size="10" font-weight="600" fill="${color}">${escape(caption.slice(0, 40))}</text>`
179 : ''
180 const sheet = `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="8" fill="${color}" fill-opacity="0.06" stroke="${color}" stroke-opacity="0.35"/>`
181 const fold = kind === 'note' ? `<path d="M${width - 18} 0.5v12a5 5 0 0 0 5 5h12.5" fill="none" stroke="${color}" stroke-opacity="0.35"/>` : ''
182 const fade = `<defs><linearGradient id="f" x1="0" y1="0" x2="0" y2="1"><stop offset="0.55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.18"/></linearGradient></defs><rect width="${width}" height="${height}" rx="8" fill="url(#f)"/>`
183 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" opacity="${isOff ? 0.45 : 1}">${sheet}${fold}${head}${textLines}${fade}</svg>`
184}
185
186// A filled track: teal into the dials' blue, or one colour.
187export function track(fraction: number, width: number, color?: string): string {
188 const filled = Math.max(fraction > 0 ? 4 : 0, Math.round(Math.min(1, Math.max(0, fraction)) * width))
189 const fill = color ?? 'url(#t)'
190 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="6" viewBox="0 0 ${width} 6"><defs><linearGradient id="t" x1="0" x2="1"><stop offset="0%" stop-color="#22c4dc"/><stop offset="100%" stop-color="#8f9cff"/></linearGradient></defs><rect width="${width}" height="6" rx="3" fill="#8b949e" fill-opacity="0.22"/><rect width="${filled}" height="6" rx="3" fill="${fill}"/></svg>`
191}
192
193// A folder's art: a folder with its members' pages fanned behind it.
194export function folderArt(count: number, sharedCount: number, width: number, height: number): string {
195 const color = GLYPH_COLOR.folder
196 const pages = Array.from({ length: Math.min(3, count) }, (_, index) => {
197 const x = width / 2 - 34 + index * 14
198 const y = 14 - index * 3
199 return `<rect x="${x}" y="${y}" width="52" height="40" rx="4" fill="#9aa1ab" fill-opacity="${0.14 + index * 0.05}" stroke="#9aa1ab" stroke-opacity="0.3" transform="rotate(${-8 + index * 8} ${x + 26} ${y + 20})"/>`
200 }).join('')
201 const body = `<path d="M${width / 2 - 52} 34a6 6 0 0 1 6-6h26l7 7h53a6 6 0 0 1 6 6v${height - 50}a6 6 0 0 1-6 6h-86a6 6 0 0 1-6-6z" fill="${color}" fill-opacity="${sharedCount > 0 ? 0.3 : 0.14}" stroke="${color}" stroke-opacity="0.8" stroke-width="1.4"/>`
202 const badge =
203 count > 0
204 ? `<text x="${width / 2}" y="${height - 16}" text-anchor="middle" ${FONT} font-size="12" font-weight="600" fill="${color}">${count}</text>`
205 : ''
206 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${pages}${body}${badge}</svg>`
207}
208
209// A branch forking off a trunk, for the Worktree tab's empty state.
210export function branchArt(width: number, height: number): string {
211 const c = '#8f9cff'
212 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 120 64"><path d="M20 52V12" stroke="#9aa1ab" stroke-opacity="0.55" stroke-width="3" stroke-linecap="round"/><path d="M20 40c0-14 18-14 40-14h22" fill="none" stroke="${c}" stroke-width="3" stroke-linecap="round" stroke-dasharray="1 7"/><circle cx="20" cy="12" r="6" fill="#9aa1ab" fill-opacity="0.5"/><circle cx="20" cy="52" r="6" fill="#9aa1ab" fill-opacity="0.5"/><rect x="84" y="14" width="26" height="24" rx="6" fill="${c}" fill-opacity="0.18" stroke="${c}" stroke-width="2"/><path d="M92 26h10M97 21v10" stroke="${c}" stroke-width="2" stroke-linecap="round"/></svg>`
213}
214
215// Three request rows, for the API log's empty state.
216export function requestsArt(width: number, height: number): string {
217 const rows = [
218 ['#3fb950', 'GET', 46],
219 ['#8fa8ff', 'POST', 60],
220 ['#d6a24a', 'PUT', 38],
221 ]
222 .map(([color, method, length], index) => {
223 const y = 6 + index * 20
224 return `<rect x="0" y="${y}" width="34" height="14" rx="4" fill="${color}" fill-opacity="0.85"/><text x="17" y="${y + 10.5}" text-anchor="middle" ${FONT} font-size="8.5" font-weight="700" fill="#16161a">${method}</text><rect x="42" y="${y + 4}" width="${length}" height="6" rx="3" fill="#9aa1ab" fill-opacity="0.4"/><circle cx="${120 - 6}" cy="${y + 7}" r="4" fill="#3fb950" fill-opacity="${index === 2 ? 0.25 : 0.8}"/>`
225 })
226 .join('')
227 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 120 66">${rows}</svg>`
228}
229
230// One icon per dial, after CommissionAI's: a compass, people, a target, a
231// steering wheel, a check in a shield and a speech bubble.
232export function dialIcon(key: 'foresight' | 'crew' | 'tenacity' | 'autonomy' | 'proof' | 'voice', color: string, size = 18): string {
233 const stroke = `fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"`
234 const shapes = {
235 foresight: `<circle cx="9" cy="9" r="7" ${stroke}/><path d="M11.8 6.2 10.2 10.2 6.2 11.8 7.8 7.8z" ${stroke}/>`,
236 crew: `<circle cx="7" cy="6.5" r="2.6" ${stroke}/><path d="M2.5 15c.4-2.8 2.2-4.2 4.5-4.2s4.1 1.4 4.5 4.2" ${stroke}/><circle cx="12.6" cy="5.8" r="2" ${stroke}/><path d="M12.4 9.6c1.9 0 3.3 1.2 3.6 3.6" ${stroke}/>`,
237 tenacity: `<circle cx="9" cy="9" r="7" ${stroke}/><circle cx="9" cy="9" r="4" ${stroke}/><circle cx="9" cy="9" r="1.2" fill="${color}"/>`,
238 autonomy: `<circle cx="9" cy="9" r="7" ${stroke}/><circle cx="9" cy="9" r="2" ${stroke}/><path d="M9 2v5M3 12.5l4.3-2.4M15 12.5l-4.3-2.4" ${stroke}/>`,
239 proof: `<path d="M9 2 15 4.5v4.3c0 3.6-2.5 6-6 7.2-3.5-1.2-6-3.6-6-7.2V4.5z" ${stroke}/><path d="m6.2 9 2 2 3.8-4" ${stroke}/>`,
240 voice: `<path d="M3 4.5A1.5 1.5 0 0 1 4.5 3h9A1.5 1.5 0 0 1 15 4.5v6a1.5 1.5 0 0 1-1.5 1.5H8l-3.5 3v-3h0A1.5 1.5 0 0 1 3 10.5z" ${stroke}/><path d="M6 6.5h6M6 9h4" ${stroke}/>`,
241 }
242 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 18 18">${shapes[key]}</svg>`
243}
244
245// A preset's shape: its six levels as a small radar, as CommissionAI's
246// PresetGlyph draws one.
247export function presetGlyph(levels: number[], color: string, size = 18): string {
248 const point = (index: number, level: number) => {
249 const angle = ((-90 + (index * 360) / levels.length) * Math.PI) / 180
250 return `${(9 + Math.cos(angle) * 7.5 * (level / 5)).toFixed(2)},${(9 + Math.sin(angle) * 7.5 * (level / 5)).toFixed(2)}`
251 }
252 const ring = levels.map((_, index) => point(index, 5)).join(' ')
253 const shape = levels.map((level, index) => point(index, level)).join(' ')
254 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 18 18"><polygon points="${ring}" fill="none" stroke="${color}" stroke-opacity="0.35"/><polygon points="${shape}" fill="${color}" fill-opacity="0.3" stroke="${color}" stroke-width="1.2" stroke-linejoin="round"/></svg>`
255}
256
257// CommissionAI's prompt-cache gauge: a flame and five bars, one per minute of
258// the cache's five, running down after each reply. Hovering it explains it.
259export function cacheGauge(secondsLeft: number, isWorking: boolean, hitPercent: number | null): string {
260 const colors = ['#5aaeff', '#2fcfa6', '#74d16f', '#a8c94a', '#d8bf45']
261 const lit = isWorking ? 5 : Math.ceil(secondsLeft / 60)
262 const isWarm = isWorking || secondsLeft > 0
263 const bars = colors
264 .map((color, index) => `<rect x="${24 + index * 9}" y="3" width="6" height="14" rx="3" fill="${index < lit ? color : '#8b949e'}" fill-opacity="${index < lit ? 1 : 0.3}"/>`)
265 .join('')
266 const flame = `<path d="M9 2.5c.6 2.7 3.6 4.3 3.6 7.9A4.6 4.6 0 0 1 3.4 10.6c0-1.6.7-2.8 1.7-3.8.1 1.3.8 2.2 1.8 2.6C6.5 6.9 7.4 4.7 9 2.5z" fill="none" stroke="${isWarm ? '#e8a33d' : '#8b949e'}" stroke-width="1.5" stroke-linejoin="round"/>`
267 const minutes = Math.floor(secondsLeft / 60)
268 const seconds = String(Math.floor(secondsLeft % 60)).padStart(2, '0')
269 const lines = isWorking
270 ? ['Prompt cache in use', 'The agent is working, reusing your chat from the cache.']
271 : isWarm
272 ? ['Prompt cache warm', 'The agent keeps your chat so far in a short-lived cache. While it is warm, your next message reuses it: faster and much cheaper.', `Bars show the time left: ${minutes}:${seconds}. The cache lasts 5 min after the last reply, then rests.`, 'Good time to send a follow-up.']
273 : ['Prompt cache cold', 'More than 5 minutes since the last reply, so the cache has rested. Your next message reads the whole chat again: slower and costlier.']
274 if (hitPercent !== null) lines.push(`The last request read ${hitPercent}% of its input from the cache.`)
275 const title = lines.map(line => escape(line)).join('\n\n')
276 return `<svg xmlns="http://www.w3.org/2000/svg" width="70" height="20" viewBox="0 0 70 20"><title>${title}</title><rect width="70" height="20" fill="transparent"/>${flame}${bars}</svg>`
277}
278
279// What a drawn control runs when pressed, by its Client's key: set while the
280// drawing is made, run by the press hook in pane.tsx.
281export const pressHandlers = new Map<string, () => void>()
282
283// What a drawn control that lights on hover runs as the pointer enters and
284// leaves it, by its Client's key; run by the same hook in pane.tsx.
285export const hoverHandlers = new Map<string, (isOn: boolean) => void>()
286
287// How a drawn control lights: its lit picture, the same size, drawn always and
288// shown in place of the one at rest while `isHot`, so no image loads on hover.
289export type DrawnHover = { hotSource: string; isHot: boolean; onHover: (isOn: boolean) => void }
290
291// A picture that presses like a Button on the desktop: the Svg with a Client
292// laid over it. Where there is no Client, the caller draws a Button instead.
293// The Client holds the pointer, so a hover reaches the plugin through it,
294// never through a Box's hover props.
295export function drawnButton(ui: Ui, key: string, source: string, alt: string, width: number, height: number, onPress: () => void, hover?: DrawnHover) {
296 const { Box, Svg, Client } = ui
297 if (Svg === undefined || Client === undefined) return null
298 pressHandlers.set(key, onPress)
299 if (hover !== undefined) hoverHandlers.set(key, hover.onHover)
300 return (
301 <Box key={`drawn-${key}`} position="relative" flexShrink={0}>
302 <Svg source={source} alt={alt} width={width} height={height} />
303 {hover !== undefined && (
304 <Box key={`hot-${key}`} position="absolute" top={0} left={0} display={hover.isHot ? 'flex' : 'none'}>
305 <Svg source={hover.hotSource} alt={alt} width={width} height={height} />
306 </Box>
307 )}
308 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
309 <Client key={key} module="./press.tsx" props={{ hovers: hover !== undefined }} width="100%" height="100%" />
310 </Box>
311 </Box>
312 )
313}
314
315export const ON_OFF = [
316 { value: 'off', label: 'Off' },
317 { value: 'on', label: 'On' },
318] as const
319
320// A tab's own on/off switch, its name beside it: "Use worktrees", "Use dials".
321export function useSwitch(ui: Ui, key: string, name: string, isOn: boolean, onChange: (isOn: boolean) => void) {
322 const { Box, Text } = ui
323 return (
324 <Box key={`${key}-row`} flexShrink={0} gap={1} alignItems="center">
325 <Text dimColor>{name}</Text>
326 {drawnButton(ui, key, switchArt(isOn ? 'on' : 'off'), `${name}: ${isOn ? 'on' : 'off'}`, 36, 22, () => onChange(!isOn)) ??
327 segmented(ui, key, ON_OFF, isOn ? 'on' : 'off', choice => onChange(choice === 'on'))}
328 </Box>
329 )
330}
331
332// A setting's row in a card: its name on the left, its switch on the right,
333// and under it, dim, one short line of what it does now.
334export function switchRow(ui: Ui, key: string, name: string, isOn: boolean, hint: string, onChange: (isOn: boolean) => void) {
335 const { Box, Text } = ui
336 return (
337 <Box key={`${key}-row`} flexDirection="column">
338 <Box justifyContent="space-between" alignItems="center" gap={1}>
339 <Text bold>{name}</Text>
340 {drawnButton(ui, key, switchArt(isOn ? 'on' : 'off'), `${name}: ${isOn ? 'on' : 'off'}`, 36, 22, () => onChange(!isOn)) ??
341 segmented(ui, key, ON_OFF, isOn ? 'on' : 'off', choice => onChange(choice === 'on'))}
342 </Box>
343 <Text dimColor>{hint}</Text>
344 </Box>
345 )
346}
347hooks/settings.ts 51 lines1// Where fishStack keeps a setting: per repository, so each repo has its own
2// dials, switches and worktree choices. A repository is its main checkout,
3// so a session in one of its worktrees reads what the main checkout set;
4// outside git, the folder the session works in.
5import type { EngineInterface } from 'claude-code'
6
7type $ = EngineInterface
8
9// The main checkout of the session's repository, or null outside one: the
10// folder holding the repository's .git, so a session working in a linked
11// worktree finds the same pool (and settings) as one in the main checkout.
12export async function repoMain($: $): Promise<string | null> {
13 const repo = await $.session.repo()
14 if (repo === null) return null
15 const common = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: repo.root, timeoutMs: 120_000 })
16 const dir = common.stdout.trim()
17 const main = common.exitCode === 0 && dir.endsWith('/.git') ? dir.slice(0, -'/.git'.length) : repo.root
18 return (await $.fs.stat(main, { resolve: true })).realPath ?? main
19}
20
21// A folder's main checkout does not change, so it is asked of git once per
22// folder, not on every draw of the pane.
23const homes = new Map<string, Promise<string>>()
24
25export async function settingsHome($: $): Promise<string> {
26 const repo = await $.session.repo()
27 if (repo === null) return $.session.root()
28 let home = homes.get(repo.root)
29 if (home === undefined) {
30 home = (async () => (await repoMain($)) ?? repo.root)()
31 homes.set(repo.root, home)
32 home.catch(() => homes.delete(repo.root))
33 }
34 return home
35}
36
37export function repoSettingKey(key: string, home: string): string {
38 return `${key}:${home}`
39}
40
41// This repository's value, or, until it sets its own, the one fishStack
42// kept for every repository before settings were per repository.
43export async function getSetting($: $, key: string): Promise<unknown> {
44 const own = await $.store.get(repoSettingKey(key, await settingsHome($)))
45 return own !== undefined ? own : $.store.get(key)
46}
47
48export async function setSetting($: $, key: string, value: unknown): Promise<void> {
49 await $.store.set(repoSettingKey(key, await settingsHome($)), value as never)
50}
51hooks/state.ts 73 lines1// What several files share and none owns: the starting dials and worktree,
2// the presets, and what each card's head says while closed. It imports no
3// other hook file, so any of them can import it.
4import { atom, read } from 'claude-code'
5import type { EngineInterface } from 'claude-code'
6
7import type { DialKey, DialValues, Tab, Worktree } from '../types'
8
9export const BALANCED: DialValues = { foresight: 3, crew: 3, tenacity: 3, autonomy: 3, proof: 3, voice: 3 }
10
11export const PRESETS: { name: string; values: DialValues }[] = [
12 { name: 'Balanced', values: BALANCED },
13 { name: 'Quick', values: { foresight: 1, crew: 1, tenacity: 2, autonomy: 4, proof: 2, voice: 2 } },
14 { name: 'Deep work', values: { foresight: 4, crew: 3, tenacity: 4, autonomy: 3, proof: 4, voice: 3 } },
15 { name: 'Autopilot', values: { foresight: 4, crew: 3, tenacity: 5, autonomy: 5, proof: 4, voice: 2 } },
16]
17
18const DIAL_KEYS: DialKey[] = ['foresight', 'crew', 'tenacity', 'autonomy', 'proof', 'voice']
19
20export function presetName(values: DialValues): string {
21 const preset = PRESETS.find(one => DIAL_KEYS.every(key => one.values[key] === values[key]))
22 return preset?.name ?? 'Custom'
23}
24
25export const IDLE_WORKTREE: Worktree = {
26 phase: 'off',
27 step: '',
28 steps: [],
29 main: null,
30 path: null,
31 branch: null,
32 base: null,
33 baseSha: null,
34 error: null,
35}
36
37const worktree = atom({ plugin: 'fishstack', key: 'worktree' } as const, IDLE_WORKTREE)
38const elsewhere = atom({ plugin: 'fishstack', key: 'elsewhere' } as const, null)
39const dials = atom({ plugin: 'fishstack', key: 'dials' } as const, BALANCED)
40const isDialsOn = atom({ plugin: 'fishstack', key: 'isDialsOn' } as const, true)
41const autopilot = atom({ plugin: 'fishstack', key: 'autopilot' } as const, null)
42const sources = atom({ plugin: 'fishstack', key: 'sources' } as const, [])
43const isContextOn = atom({ plugin: 'fishstack', key: 'isContextOn' } as const, true)
44
45export type Head = { value: string; tone?: 'success' | 'warning' | 'error' }
46
47// Each card's state in a word or two, coloured when it wants a look.
48export async function paneHeads($: EngineInterface): Promise<Record<Tab, Head>> {
49 const state = await read($, worktree)
50 const isDials = await read($, isDialsOn)
51 const values = await read($, dials)
52 const auto = isDials && values.autonomy === 5 ? await read($, autopilot) : null
53 const list = await read($, sources)
54 const on = list.filter(one => one.mode !== 'off').length
55 const minutes = auto?.kind === 'waiting' ? Math.max(1, Math.ceil((auto.deadline - (await $.clock.now())) / 60_000)) : 0
56 return {
57 worktree:
58 state.phase === 'ready'
59 ? { value: state.branch ?? 'Worktree', tone: 'success' }
60 : state.phase === 'preparing'
61 ? { value: 'Preparing…', tone: 'warning' }
62 : state.phase === 'failed'
63 ? { value: 'Failed', tone: 'error' }
64 : { value: (await read($, elsewhere)) !== null ? 'Its own worktree' : 'Main checkout' },
65 dials: !isDials
66 ? { value: 'Off' }
67 : auto?.kind === 'waiting'
68 ? { value: `Answers in ${minutes} min`, tone: 'warning' }
69 : { value: presetName(values) },
70 context: !(await read($, isContextOn)) ? { value: 'Off' } : list.length === 0 ? { value: 'None yet' } : { value: `${on} of ${list.length} on` },
71 }
72}
73hooks/ui.tsx 117 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import type { Tab } from '../types'
4import { wordmarkArt } from './art'
5import { card } from './kit'
6import type { Head } from './state'
7
8export const PANE = 'fishstack'
9export const PANE_TITLE = 'fishStack'
10// How wide the pane asks to dock: a sidebar, as the desktop's own panel is.
11export const PANE_COLUMNS = 46
12
13// The elements the pane draws with, so it draws on every surface: mobile has
14// no Input or Select, the terminal no Svg, and the tabs leave those out where
15// they are missing.
16export type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Markdown' | 'Code'> &
17 Partial<Pick<Elements['terminal'], 'Input' | 'Select'>> &
18 Partial<Pick<Elements['desktop'], 'Svg' | 'Client'>>
19
20// $.ui.resolve completes every table with stand-ins that draw nothing; this
21// leaves out what the surface lacks, so the tabs can test for it: the
22// terminal draws no Svg, mobile no Input or Select. A Client that failed
23// (ui.fault) leaves them all out until a reload, the Buttons standing in.
24export function resolveUi(table: Ui, surface: string, isClientDown: boolean): Ui {
25 return {
26 ...table,
27 Svg: surface === 'terminal' ? undefined : table.Svg,
28 // Drawn controls take presses through a Client, which only the desktop
29 // lays over a picture; the terminal keeps its Buttons.
30 Client: surface === 'desktop' && !isClientDown ? table.Client : undefined,
31 Input: surface === 'mobile' ? undefined : table.Input,
32 Select: surface === 'mobile' ? undefined : table.Select,
33 }
34}
35
36export const TABS: { id: Tab; label: string; icon: string }[] = [
37 { id: 'worktree', label: 'Worktree', icon: '⎇' },
38 { id: 'dials', label: 'Dials', icon: '◇' },
39 { id: 'context', label: 'Context', icon: '▤' },
40]
41
42export function isTab(value: string): value is Tab {
43 return TABS.some(one => one.id === value)
44}
45
46// One card: its head names it on the left and says its state on the right,
47// a press opens or closes it, and while open its body sits under the head.
48export function cardOf(ui: Ui, id: Tab, isOpen: boolean, head: Head, onToggle: () => void, body: RenderElement | null) {
49 const { Box, Button, Text } = ui
50 const index = TABS.findIndex(one => one.id === id)
51 const one = TABS[index]!
52 return card(
53 ui,
54 `card-${id}`,
55 [
56 <Box key={`head-${id}`} justifyContent="space-between" alignItems="center" gap={1}>
57 <Button key={`tab-${id}`} plain hotkey={String(index + 1)} onPress={onToggle}>
58 <Text color="suggestion">{one.icon} </Text>
59 <Text bold>{one.label}</Text>
60 </Button>
61 <Button key={`tab-${id}-state`} plain onPress={onToggle}>
62 <Text color={head.tone} dimColor={head.tone === undefined}>
63 {head.value}
64 </Text>
65 <Text dimColor> {isOpen ? '⌄' : '›'}</Text>
66 </Button>
67 </Box>,
68 isOpen && body !== null && (
69 <Box key={`body-${id}`} flexDirection="column" gap={1} marginTop={1}>
70 {body}
71 </Box>
72 ),
73 ],
74 { tone: isOpen ? 'selected' : 'plain' },
75 )
76}
77
78// The pane is one column of cards, as the desktop's own side panel is, one
79// open at a time. Each tab's file draws the whole pane while its card is
80// open; pane.tsx draws it with every card closed.
81export function frame(ui: Ui, current: Tab | null, columns: number, onSelect: (id: Tab | null) => void, body: RenderElement | null, heads: Record<Tab, Head>) {
82 const { Box, Svg } = ui
83 const rich = Svg !== undefined
84 return (
85 <Box flexDirection="column" width={columns} gap={rich ? 1 : 0}>
86 {rich && (
87 <Box key="pane-head">
88 <Svg source={wordmarkArt().source} alt="fishStack" width={wordmarkArt().width} height={wordmarkArt().height} />
89 </Box>
90 )}
91 {TABS.map(one => cardOf(ui, one.id, one.id === current, heads[one.id], () => onSelect(one.id === current ? null : one.id), one.id === current ? body : null))}
92 </Box>
93 )
94}
95
96// How wide a card opened from the rail is drawn above the prompt: room for
97// its rows, never the whole width of a wide window.
98export const BAND_CARD_COLUMNS = 72
99
100// How wide each card of a grid is, so the cards fill the row: as many as fit
101// at `least` columns each, with one column between them.
102export function gridWidth(columns: number, least: number): number {
103 const perRow = Math.max(1, Math.floor((columns + 1) / (least + 1)))
104 return Math.floor((columns - (perRow - 1)) / perRow)
105}
106
107export function bar(fraction: number, width: number): string {
108 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
109 return '█'.repeat(filled) + '░'.repeat(width - filled)
110}
111
112export function tokens(count: number): string {
113 if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
114 if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`
115 return String(count)
116}
117