SLOPSHOPPER

fishstack

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

newpanebandrowsguardcommand
v0.8.1MITupdated 2026-10-16patrick-lai/claude-fish-stack-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fishstack
│ ┃ fishstack ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 1: tab-worktree tab-work ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ Use worktrees [ Of ⏺ Update(src/auth.ts) │ ┃ │ Sessions work in the main checkout. ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ This session: main checkout ⎿ 3 pass, 1 fail │ ┃ │ [ Create worktree now ] │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 2: tab-dials tab-d ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── › /fishstack │ ┃ │ 3: tab-context tab-con ⎿ fishstack: Opened fishStack · Worktree, above the prompt. │ ┃ ╰─────────────────────────────────────────── │ open-fishstack ▮▮▮▮▮ Cache warm 5:00 ↑97k in ↓1.5k out · ≈ $0.42 API price rail-worktree rail-dials rail-source ╭──────────────────────────────────────────────────────────────────────╮ │ 1: tab-worktree tab-worktree-state │ │ │ │ Use worktrees [ Off ] [ On ] │ │ Sessions work in the main checkout. │ │ │ │ This session: main checkout │ │ [ Create worktree now ] │ ╰──────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
open-fishstack ▮▮▮▮▮ Cache warm 5:00 ↑97k in ↓1.5k out · ≈ $0.42 API price rail-worktree rail-di ╭──────────────────────────────────────────────────────────────────────╮ │ 1: tab-worktree tab-worktree-state │ │ │ │ Use worktrees [ Off ] [ On ] │ │ Sessions work in the main checkout. │ │ │ │ This session: main checkout │ │ [ Create worktree now ] │ ╰──────────────────────────────────────────────────────────────────────╯
Pane · fishstack
╭──────────────────────────────────────────────────────╮ │ 1: tab-worktree tab-worktree-state │ │ │ │ Use worktrees [ Off ] [ On ] │ │ Sessions work in the main checkout. │ │ │ │ This session: main checkout │ │ [ Create worktree now ] │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 2: tab-dials tab-dials-state │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 3: tab-context tab-context-state │ ╰──────────────────────────────────────────────────────╯
README

<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:

  • Sessions trip over each other. Two agents in one checkout edit the same files, and a fresh worktree has no node_modules.
  • You keep repeating yourself. "Write a spec first." "Don't ask, just decide." "Run the tests." You type it every session.
  • The agent forgets what matters. API conventions, the design doc, the ticket: pasted in again and again.

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. |

Install

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.

Worktrees

<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:

  • A warm pool. fishStack keeps a pool of slots in the background (three to start, with one always free), each checked out, with dependencies in place and your setup command already run. A new session takes one and moves in. In the run above that took about three seconds from launch. The card's Pool line opens the list of every slot: + Add slot grows the pool, and a new slot shows up straight away as setting up with the step it is on, until it's ready. Remove (a second press confirms) takes a free slot out, and the pool stays that size from then on (it still keeps one free for the next session).
  • A head start. While the slot is prepared, the agent can already read, search and plan. Anything that writes waits until it has moved in.
  • No copying. node_modules is cloned copy-on-write (APFS), so a slot costs almost no disk until something changes.
  • Nothing lost. A slot is reused once its session ends. Anything left uncommitted is saved under refs/fish/backup/ first.
  • The default branch stays free. Git lets only one worktree have a branch at a time, so a worktree on 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).

The desktop app, with no checkout

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">

  1. Fetch. git fetch origin <default>. If that fails, it starts from the local branch and says so.
  2. Claim. Under a per-repository lock (an atomic 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.
  3. Seed. Each missing 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.
  4. Move in. The session enters the slot (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.

Dials

<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.

Enforced, not only asked

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:

  • Capped. An edit is refused twice at most per request, then let through, and the end of a request is held back once at most, never in a loop.
  • Main agent only. Subagents and doc-only changes are left alone.
  • Fails open. Any check that can't decide lets things through.
  • Switchable. The Enforce switch turns it all off and leaves the dials as prompts alone.

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.

Autopilot answers when you're away

<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.

Context

<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

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.

Options

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.

Develop

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:

  • Terminal GIFs (claim, autopilot, context): real Claude Code with this plugin, recorded in tmux by e2e/reel/scenes.sh.
  • Desktop pictures (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.

The mark

"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.

License

MIT © Patrick Lai

Source 20 files
hooks/register.tsx 18 lines
1import 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}
18
hooks/context.tsx 630 lines
1// 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(/&nbsp;/g, ' ')
117    .replace(/&lt;/g, '<')
118    .replace(/&gt;/g, '>')
119    .replace(/&quot;/g, '"')
120    .replace(/&#39;/g, "'")
121    .replace(/&amp;/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}
630
hooks/dials.tsx 421 lines
1// 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}
421
hooks/guard.ts 130 lines
1import { 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}
130
hooks/pane.tsx 91 lines
1import { 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}
91
hooks/session.ts 347 lines
1// 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}
347
hooks/worktree.tsx 1747 lines
1// 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 lines
1// 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}
70
hooks/kit.tsx 347 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
347
hooks/settings.ts 51 lines
1// 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}
51
hooks/state.ts 73 lines
1// 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}
73
hooks/ui.tsx 117 lines
1import 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