Diagnostics and operations skills for the sandbox: full-stack health checks, URL routing tracing, safe Caddy reloads, and status overviews.

A Claude Code plugin bundling the diagnostic and operations skills for the sandbox environment.
Paths:
~/.werkmeister/and a workspace's.werkmeister/are the current names. A machine or workspace set up before the rename keeps~/.sandbox/or.sandbox/instead; use whichever exists.
| Skill | What it does |
|---|---|
status | Comprehensive health overview of the whole sandbox (containers, networks, proxy). |
diagnose | Full-stack health checks across containers, networking, Caddy routing, and connectivity. |
routing-test | Trace a single URL through the routing chain to find where it breaks. |
caddy-reload | Validate, reload, and smoke-test the Caddy proxy config safely. |
| Skill | What it does |
|---|---|
workspace | Create a brand-new workspace (git worktrees + init). Uses the bundled werkmeister MCP server (see below) for the form; falls back to the direct CLI. Self-contained — no external skill dependency. |
feature-test | Start a feature environment and open a headed Playwright browser for interactive testing. |
starting-prompt | Run the starting_prompt defined in the workspace's .werkmeister/workspace.yml. |
| Mod | What it does | |
|---|---|---|
/workspaces | Opens by itself when an interactive session starts in a folder of workspaces (one whose child holds .werkmeister/workspace.yml or the older .sandbox/workspace.yml, e.g. ~/features). A docked pane with one compact card per workspace (docs/ui-spec.md, docs/design/workspace-card/design/Pane View.dc.html), drawn from the shared display model (hooks/display-model.ts, a copy of packages/werkmeister-ui's that packages/werkmeister-ui/test/pane-copy.test.mjs keeps in step). Line 1: the name, the agent chip (◌ Working · ● Needs you · ✓ Done · Idle, from SW-46's agent state), and ⚙ Auto for an autonomous run. Line 2: work type · task from workspace.yml. Line 3, always in this order: containers 2 of 3 up · PRs repo#12 Open · CI running · stage 4/5 implement. The last part is the Workflow: the ICM stage, the JIT phase, or the lifecycle step (Ready to close out, Awaiting release; docs/workspace-lifecycle.md). A Needs you card quotes the question it asked, and is the only card with an amber border. ? beside the tabs shows the legend. Below these sit the primary action by state (▶ Resume while paused, Continue, ✓ Close out once shipped, or ▶ Up while nothing runs), two lifecycle buttons (■ Stop · ⏸ Pause while containers run), and ⋯ with the rest in the ui-spec order. Up / Stop / Pause / Resume / Restart / Down run werkmeister-core in a node child (a plugin's call to its own MCP server is refused by the desktop's auto mode; a host process is not). ↻ Refresh PRs runs sandbox pr status and re-polls the PRs; it is also the retry when a card says PRs unavailable. The end verb is sandbox close-out (docs/workspace-lifecycle.md): ✓ Close out once every PR shipped, and ✕ Abandon…, which asks for the reason in the pane. Each confirms inline in the card before it runs, then archive every desktop session whose cwd is in the workspace (ccd_session_mgmt archive_session; a refused one is reported as left open) and list terminal sessions still open there to /exit. The same archive runs after a successful close-out MCP tool call, through a tool.call hook, since that server can't reach the app's session tools. + New session opens a new Claude Code Desktop session in the workspace folder (claude://code/new?folder=…, the link the app's Finder service uses). ↗ Continue in Desktop / VS Code / Terminal picks up the newest session where it lives: it opens the Desktop session in split view beside this chat (else opens its own link, which takes this chat's place, and toasts how to split it from the sidebar), opens the folder in VS Code for a VS Code session, else resumes it in a Terminal window. ⇆ Open beside shows the session in split view next to this one so the pane stays in sight. It appears only for a session this one started (its startedBy), the only kind the app will split. 📌 Pin / Unpin pins or unpins that session in the app's sidebar (ccd_sidebar set_pinned; desktop only). A Workspaces \ | + Create tab bar heads the pane; on the Workspaces tab, ☐ Focused only shows only workspaces whose .werkmeister/workspace.yml labels include focus, and the choice survives a plugin reload. The Create tab draws the workspace form from the same schema as the browser form (fields, grouped checkbox columns, a multi-line starting prompt with Refine with Claude) with a button that fills empty fields from the issue, and creates the workspace directly. A desktop session that starts inside a workspace files itself under the sidebar group named Feature Workspaces, if that group exists and the session is in no group yet (ccd_sidebar move_sessions). The workspace_form tool lets Claude fill the form from the prompt box (where pasting works); it never creates the workspace. Source: hooks/register.tsx. |
Probe findings (2026-10-09): change_directory from a plugin call: yes, after the person approves the folder. claude:// link in a pane focuses the session: no, the desktop pane draws nothing for it. Fallback taken: the session entry is text plus a Jump button that calls ccd_window open_session_in, which only focuses sessions this one started; otherwise it toasts the title to open from the sidebar.
To load it from a checkout instead of the marketplace, set CLAUDE_CODE_PLUGIN_DIRS to this folder in the env block of ~/.claude/settings.json.
sb-*) — NOT in this pluginThe sb-implement / sb-research entry skills and the sb-*-standard code standards are not shipped here. They are projected into each workspace's .claude/skills/ by sandbox init-workspace: entry-skill sources live in apps/cli/infra/skills/, and the standards are rendered at the same step from the tree overrides.standards_dir points at (e.g. a jbbz-sandbox/sandbox-standards checkout), which sandbox standards check validates.
werkmeister)The plugin's .mcp.json auto-wires the one werkmeister MCP server (werkmeister mcp, source in apps/werkmeister-mcp) exposing every tool — workspace create/manage and lifecycle, PR review, issue lookups, k8s/kube health, and a Claude session bridge — with one form definition and graceful UI degradation per client:
| Surface | How it connects | UI |
|---|---|---|
| Claude Desktop | claude_desktop_config.json (Desktop doesn't install plugins) | Inline MCP App (ui://): create form + workspace dashboard |
| Claude Code CLI / VS Code | this plugin's .mcp.json | Localhost browser: dashboard (open-workspaces-ui) + blocking create form |
The server detects whether the client renders MCP App UI (io.modelcontextprotocol/ui capability); every tool also returns markdown/text, so any host degrades gracefully. Call the create tool directly: mcp__plugin_werkmeister_werkmeister__create-workspace-form (optionally { "issue": "<issue ID>" }). Full tool table in the server README.
Plugins don't install in Desktop, so add the same server to ~/Library/Application Support/Claude/claude_desktop_config.json:
{ "mcpServers": { "werkmeister": { "command": "werkmeister", "args": ["mcp"] } } }
cd apps/werkmeister-dashboard && pnpm run build once — it builds the ui:// HTML, the browser form and the localhost dashboard that werkmeister mcp serves. Without it the server still runs; its App tools fall back to text.w9r CLI (package werkmeister 0.14 or later) must be on PATH, and Docker running.apps/werkmeister-dashboard — no external skill is required. Issues come from the tracker picked in sandbox setup; refine uses the local claude CLI.This repo doubles as a plugin marketplace. From any Claude Code session (installs at user scope, so the skills + workspace MCP server are available in every session, not just inside this repo):
/plugin marketplace add jbbz-sandbox/werkmeister
/plugin install werkmeister@werkmeister
Skills are namespaced by the plugin: /werkmeister:diagnose, /werkmeister:workspace, …
To develop/test locally from a checkout:
/plugin marketplace add /path/to/werkmeister
/plugin install werkmeister@werkmeister
Validate changes before committing:
claude plugin validate ./plugins/werkmeister
claude plugin validate . # validates the marketplace manifest
The full check, from plugins/werkmeister:
claude plugin validate . && claude plugin test . && npx -p typescript tsc -p . --noEmit
tsc reads its types from .claude-plugin/types/, which is gitignored: Claude Code writes it beside the plugin the first time a session loads the folder as a plugin (claude --plugin-dir plugins/werkmeister, or a hot-reloaded dev mod). A fresh worktree has none, so tsc reports Cannot read file '.claude-plugin/types/tsconfig.json'. The types are Claude Code's, not this repo's, so they are not committed. Until a session has loaded the folder, point tsc at the claude-code.d.ts the plugin-authoring skill writes (its path is printed when the skill loads) with a throwaway tsconfig kept outside the repo:
{ "compilerOptions": { "target": "es2023", "lib": ["es2023"], "types": [], "module": "esnext",
"moduleResolution": "bundler", "strict": true, "noUncheckedIndexedAccess": true, "noEmit": true,
"skipLibCheck": true, "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment" },
"include": ["<path to claude-code.d.ts>", "<repo>/plugins/werkmeister/hooks", "<repo>/plugins/werkmeister/types"] }
then npx -p typescript tsc -p <that tsconfig>. TypeScript is not a dependency here, so a bare npx tsc fails with "This is not the tsc command you are looking for".
sandbox-toolsSW-9 renamed the plugin, the marketplace, the skills and the MCP servers. Claude Code has no alias for a renamed plugin, and an already-added sandbox marketplace stops resolving sandbox-tools once its manifest says werkmeister (an update reports success, then Plugin "sandbox-tools" not found), so an old install does not move on its own.
| Was | Is |
|---|---|
plugin sandbox-tools | werkmeister |
marketplace sandbox | werkmeister (install id werkmeister@werkmeister) |
skills sandbox-workspace, sandbox-diagnose, sandbox-feature-test, sandbox-status, sandbox-caddy-reload, sandbox-routing-test, sandbox-starting-prompt | /werkmeister:workspace, :diagnose, :feature-test, :status, :caddy-reload, :routing-test, :starting-prompt |
plugin MCP server sandbox-workspace (mcp__plugin_sandbox-tools_sandbox-workspace__*) | werkmeister (mcp__plugin_werkmeister_werkmeister__*; it was workspaces until SW-48) |
Claude Desktop entry "sandbox-workspace" (mcp__sandbox-workspace__*) | "werkmeister" (mcp__werkmeister__*; it was "workspaces" until SW-48) |
registry MCP server sandbox, tools sandbox-up, sandbox-status, sandbox-workspace, … | werkmeister, tools up, status, create-workspace, … (every sandbox- prefix dropped; SW-48 then folded the workspaces server in) |
One step: update the CLI to werkmeister 0.14 or later, then run
w9r setup
It adds the werkmeister marketplace, installs werkmeister@werkmeister, and only then uninstalls sandbox-tools@sandbox and removes the sandbox marketplace. w9r setup --check lists an old install as a failed step until you do. init-workspace also warns about it.
By hand, in a Claude Code session:
/plugin marketplace add jbbz-sandbox/werkmeister
/plugin install werkmeister@werkmeister
/plugin uninstall sandbox-tools@sandbox
/plugin marketplace remove sandbox
Checkout installs that set CLAUDE_CODE_PLUGIN_DIRS to .../plugins/sandbox-tools stop loading the plugin: the plugins/sandbox-tools link was removed (SW-23). Point it at .../plugins/werkmeister, and rename the sandbox entry in extraKnownMarketplaces to werkmeister (or delete it).
Also update by hand:
claude_desktop_config.json, rename the sandbox-workspace server to werkmeister, as in Register on Claude Desktop./sandbox-diagnose).Compatibility for one release: the generated workspace permission rules allow both the old and the new mcp__plugin_… prefixes, so a machine that has not run w9r setup yet keeps working. The old sandbox entry in a workspace's .mcp.json is dropped at the next init-workspace.
hooks/register.tsx 2150 lines1import type { Color, EngineInterface, Register } from "claude-code";
2
3import type {
4 Draft,
5 FormBundle,
6 FormField,
7 InfraStatus,
8 InterceptRow,
9 InterceptsSnapshot,
10 RepoPr,
11 ServiceHealth,
12 WorkspaceResources,
13 WorkspaceRow,
14} from "../types";
15import { displayModel, hostLine, type Tone, type WorkspaceDisplay } from "./display-model";
16
17export const PLUGIN = "werkmeister";
18export const PANE = "workspaces";
19
20// Workspace reads and actions run werkmeister-core in a node child, the way the former /workspace-form mod did:
21// the plugin's own MCP server is not answerable from a hook in the desktop host, a host process is.
22// stdin carries one JSON request { op, ...args }; stdout ends with one JSON line { ok, result | error }.
23// The core package is found from $WERKMEISTER_CORE_PATH or $SANDBOX_CORE_PATH, else from where the `werkmeister`
24// (or deprecated `sandbox`) binary resolves its dependencies: `werkmeister-core`, or `sandbox-core` before SW-6's rename.
25const SCRIPT = `
26import { createRequire } from 'node:module'
27import { execFileSync } from 'node:child_process'
28import { realpathSync, readFileSync, readdirSync, openSync, readSync, closeSync } from 'node:fs'
29import { homedir } from 'node:os'
30import { dirname, join } from 'node:path'
31import { pathToFileURL } from 'node:url'
32const out = o => console.log(JSON.stringify(o))
33try {
34 let s = ''
35 for await (const c of process.stdin) s += c
36 const { op, ...args } = JSON.parse(s)
37 let file = process.env.WERKMEISTER_CORE_PATH ?? process.env.SANDBOX_CORE_PATH
38 if (!file) {
39 const which = execFileSync('sh', ['-c', 'command -v werkmeister || command -v sandbox']).toString().trim()
40 const bin = realpathSync(which)
41 // npm's global bin is a symlink into the package. pnpm's (pnpm link --global) is a shell shim
42 // in PNPM_HOME, with the package under PNPM_HOME/global/<n>/node_modules.
43 const pnpmGlobal = join(dirname(which), 'global')
44 const dirs = [dirname(bin)]
45 try { for (const v of readdirSync(pnpmGlobal)) dirs.push(join(pnpmGlobal, v, 'node_modules', 'werkmeister')) } catch {}
46 for (const dir of dirs) {
47 for (const name of ['werkmeister-core', 'sandbox-core']) {
48 if (!file) try { file = createRequire(dir + '/x').resolve(name) } catch {}
49 }
50 }
51 if (!file) throw new Error('Cannot find werkmeister-core next to ' + which + '; set WERKMEISTER_CORE_PATH')
52 }
53 const core = await import(pathToFileURL(file).href)
54 const yaml = createRequire(file)('yaml')
55 const str = v => (typeof v === 'string' && v.trim() ? v.trim() : null)
56 const lifecycleOf = dir => { try { return core.lifecycleOf(dir) } catch { return null } }
57 // The transcript's "entrypoint" field (claude-vscode, claude-desktop, sdk-cli, ...) from its first 64 KiB; null when unknown.
58 const originOf = (dir, sessionId) => {
59 const enc = p => p.replace(/[^a-zA-Z0-9]/g, '-')
60 const root = join(homedir(), '.claude', 'projects')
61 let fd = null
62 try {
63 const proj = readdirSync(root).find(n => enc(n) === enc(dir))
64 if (!proj) return null
65 fd = openSync(join(root, proj, sessionId + '.jsonl'), 'r')
66 const buf = Buffer.alloc(65536)
67 const head = buf.toString('utf8', 0, readSync(fd, buf, 0, buf.length, 0))
68 return head.match(/"entrypoint":"([^"]+)"/)?.[1] ?? null
69 } catch { return null } finally { if (fd !== null) closeSync(fd) }
70 }
71 const tracker = (await import(pathToFileURL(join(dirname(file), 'issue_tracker.js')).href)).issueTracker()
72 // core.icmStage is absent from a core older than SW-47: show no stage rather than fail the list.
73 const details = async w => {
74 let cfg = {}
75 for (const d of ['.werkmeister', '.sandbox']) {
76 try { cfg = yaml.parse(readFileSync(join(w.path, d, 'workspace.yml'), 'utf8')) ?? {}; break } catch { /* not initialised */ }
77 }
78 let newest = null
79 try { newest = (await core.listSessions(w.name))[0] ?? null } catch { /* no sessions */ }
80 const sessionOrigin = newest ? originOf(w.path, newest.sessionId) : null
81 return { ...w, issueUrl: w.issueId ? tracker.url(w.issueId) : null, task: str(cfg.task), description: str(cfg.description), workType: str(cfg.work_type), feature: str(cfg.feature), stage: core.icmStage ? await core.icmStage(w.path) : null, lifecycle: lifecycleOf(w.path), sessionTitle: newest?.title ?? null, sessionOrigin }
82 }
83 // Siblings of the resolved entry: the main entry exports none of the intercept functions.
84 const sibling = name => import(pathToFileURL(join(dirname(file), name)).href)
85 const interceptArgs = async () => ({
86 feature: args.feature,
87 service: args.service,
88 localServices: (await sibling('lifecycle.js')).readServicesFromWorkspace(await core.resolveWorkspaceDir(args.name)),
89 })
90 const ops = {
91 'list-workspaces': async () => { const r = await core.listWorkspaces(); return { ...r, workspaces: await Promise.all(r.workspaces.map(details)) } },
92 'workspace-status': () => core.getWorkspaceStatuses(),
93 'claim-resume-nudge': async () => ({ message: await core.claimResumeNudge(args.name) }),
94 'workspace-resources': () => core.getWorkspaceResources(),
95 // The same snapshot \`sandbox status --intercepts --json\` prints: the CLI stays the one source of intercept state.
96 intercepts: async () => (await sibling('status.js')).interceptsSnapshot(),
97 // docker mode from the pane: it has no TTY for host mode's root-daemon sudo prompt (SW-12).
98 'intercept-start': async () => (await sibling('telepresence.js')).startIntercept({ ...(await interceptArgs()), dockerDaemon: args.dockerDaemon === true }),
99 'intercept-leave': async () => ({ left: await (await sibling('telepresence.js')).leaveIntercept({ ...(await interceptArgs()), dockerDaemon: args.dockerDaemon === true }) }),
100 'workspace-prs': () => core.getWorkspacePRs(),
101 connect: async () => ({ ok: await (await import(pathToFileURL(join(dirname(file), 'infra.js')).href)).up() }),
102 'workspace-action': () => core.runWorkspaceAction(args.name, args.action),
103 'workspace-path': async () => ({ path: await core.resolveWorkspaceDir(args.name) }),
104 'pr-status': async () => ({ command: (await core.refreshPrStatus(args.path)).lines.join('\\n') || 'no PR files' }),
105 'pr-watch': () => core.watchPrs({ desktop: true }),
106 'close-out': () => core.closeOut(args.name, args.abandon !== undefined ? { abandon: args.abandon } : {}),
107 'claude-resume': () => core.resumeClaudeInTerminal(args.name),
108 'open-vscode': () => { execFileSync('open', ['-a', 'Visual Studio Code', args.path]); return { path: args.path } },
109 // Only claude:// links: a new Desktop session in a folder, or a session's own link.
110 'open-claude-link': () => {
111 // No regex here: this script is a template literal, where a \\/ escape collapses to / and starts a comment.
112 if (!String(args.url).startsWith('claude://')) throw new Error('not a claude:// link: ' + args.url)
113 execFileSync('open', [args.url])
114 return { url: args.url }
115 },
116 'form-schema': async () => ({ bundle: await core.getFormSchema(), trackerLabel: tracker.label }),
117 'issue-form-request': () => core.buildIssueFormRequest(args.ref),
118 'issue-form-apply': () => core.applyIssueFormAnswer(args.base, args.answer),
119 // open: false: the pane opens a Desktop session in the new folder instead of VS Code.
120 'tracker-auth': () => tracker.checkAuth(),
121 // The pane distils on the session's own model (fetchIssue) before Create and sends distill: false.
122 'create-workspace': () => core.createWorkspace(args.formData, { open: false, distill: args.distill }),
123 'refine-request': () => core.buildRefinePrompt(args.text, args.instruction, args.formData),
124 // The create form's rules live in werkmeister-core, shared with the browser form.
125 'validate-form': () => core.validateFormData(args.bundle, args.formData),
126 'merge-form': () => core.mergeFormData(args.current, args.incoming),
127 }
128 if (!ops[op]) throw new Error('unknown op ' + op)
129 out({ ok: true, result: await ops[op]() })
130} catch (e) {
131 out({ ok: false, error: (e && e.message) || String(e) })
132}
133`;
134
135/** One werkmeister-core call in a node child; throws with the child's error text. */
136async function core<T>(
137 $: EngineInterface,
138 op: string,
139 args: Record<string, unknown> = {},
140 timeoutMs = 30000,
141): Promise<T> {
142 const r = await $.process.run(["node", "--input-type=module", "-e", SCRIPT], {
143 stdin: JSON.stringify({ op, ...args }),
144 timeoutMs,
145 });
146 const line = r.stdout.trim().split("\n").pop() ?? "";
147 let o: { ok: boolean; result?: T; error?: string };
148 try {
149 o = JSON.parse(line);
150 } catch {
151 // The first error line names the failure; node's last stderr line is often only its version banner.
152 const lines = r.stderr.trim().split("\n");
153 throw new Error(
154 lines.find((l) => /Error\b/.test(l)) ||
155 lines.pop() ||
156 `werkmeister-core gave no result (exit ${r.exitCode})`,
157 );
158 }
159 if (!o.ok || o.result === undefined) throw new Error(o.error ?? "werkmeister-core failed");
160 return o.result;
161}
162export const POLL_MS = 5000;
163/** PRs refresh this often: each workspace × repo is a `gh` network call. */
164export const PR_POLL_MS = 60000;
165/** CPU/memory refresh this often: the stats sweep costs the daemon about a second (SW-41). */
166export const RESOURCE_POLL_MS = 30000;
167/** PR callbacks (#236): every desktop session runs a pass this often; the core's lock lets one through. */
168export const CALLBACK_POLL_MS = 600000;
169
170const ROWS = { plugin: "werkmeister", key: "rows" } as const;
171const ERROR = { plugin: "werkmeister", key: "error" } as const;
172const BUSY = { plugin: "werkmeister", key: "busy" } as const;
173const CONFIRM = { plugin: "werkmeister", key: "confirmEnd" } as const;
174const REASON = { plugin: "werkmeister", key: "abandonReason" } as const;
175const NOTE = { plugin: "werkmeister", key: "note" } as const;
176const TAB = { plugin: "werkmeister", key: "tab" } as const;
177const FOCUS = { plugin: "werkmeister", key: "focus" } as const;
178const TRACKER = { plugin: "werkmeister", key: "tracker" } as const;
179export const TRACKER_TTL_MS = 5 * 60 * 1000;
180let trackerAt = 0;
181const INFRA = { plugin: "werkmeister", key: "infra" } as const;
182const RESOURCES = { plugin: "werkmeister", key: "resources" } as const;
183const INTERCEPTS = { plugin: "werkmeister", key: "intercepts" } as const;
184/** CONFIRM prefix for a pending intercept ON: `intercept:<workspace>/<service>`. */
185const INTERCEPT = "intercept:";
186const PRS = { plugin: "werkmeister", key: "prs" } as const;
187const PRS_POLL = { plugin: "werkmeister", key: "prsPoll" } as const;
188const MENU = { plugin: "werkmeister", key: "menu" } as const;
189const LEGEND = { plugin: "werkmeister", key: "legend" } as const;
190/** BUSY while `sandbox connect` runs; no workspace name has parentheses. */
191const CONNECTING = "(connect)";
192
193const FORM = { plugin: "werkmeister", key: "formSchema" } as const;
194const DRAFT = { plugin: "werkmeister", key: "draft" } as const;
195const FORM_BUSY = { plugin: "werkmeister", key: "formBusy" } as const;
196const FOCUSED = { plugin: "werkmeister", key: "focusedField" } as const;
197
198/** Mobile draws no Input or Select, so there the Create tab hands the form to the model instead. */
199export function createPrompt(issueId: string): string {
200 const id = issueId.trim();
201 return `Create a new sandbox workspace with the werkmeister:workspace skill${id ? `, pre-filled from issue ${id}` : ""}.`;
202}
203
204/**
205 * The create form's fields from the same RJSF bundle the browser form renders
206 * (getFormSchema: the terminal wizard's prompts), in its `ui:order`. An array
207 * with choices is a multi-pick; a string with choices a select; a boolean a
208 * checkbox; a `textarea` widget a multi-line field; the rest one line.
209 */
210export function formFields(bundle: FormBundle): FormField[] {
211 const props = bundle.schema.properties ?? {};
212 const order = (bundle.uiSchema["ui:order"] as string[] | undefined) ?? Object.keys(props);
213 const required = new Set(bundle.schema.required ?? []);
214 return order.flatMap((k) => {
215 const p = props[k];
216 if (!p) return [];
217 const ui = (bundle.uiSchema[k] ?? {}) as Record<string, unknown>;
218 const options = (p.type === "array" ? p.items?.enum : p.enum) ?? [];
219 return {
220 key: k,
221 title: p.title ?? k,
222 help: p.description ?? "",
223 kind:
224 p.type === "boolean"
225 ? "check"
226 : options.length
227 ? p.type === "array"
228 ? "multi"
229 : "select"
230 : ui["ui:widget"] === "textarea"
231 ? "textarea"
232 : "text",
233 options,
234 groups: ((ui["ui:options"] ?? {}) as { groups?: Record<string, string[]> }).groups ?? {},
235 refine: refineOf(ui),
236 required: required.has(k),
237 pattern: p.pattern ?? null,
238 placeholder: typeof ui["ui:placeholder"] === "string" ? ui["ui:placeholder"] : "",
239 } satisfies FormField;
240 });
241}
242
243/** A refinable field's instruction for refinePrompt ("" = its default), or null when the field isn't refinable. */
244function refineOf(ui: Record<string, unknown>): string | null {
245 const o = (ui["ui:options"] ?? {}) as { refinable?: boolean; refineInstruction?: string };
246 return o.refinable ? (o.refineInstruction ?? "") : null;
247}
248
249/** A multi-pick draws as checkboxes in this many columns, as the browser form does. */
250const COLUMNS = 5;
251
252/** [heading, options] in schema order: the bundle's groups (an option in none goes under "other"), else one unheaded group. */
253export function optionGroups(f: FormField): [string, string[]][] {
254 const headings = Object.keys(f.groups);
255 if (!headings.length) return [["", f.options]];
256 const grouped = new Set(Object.values(f.groups).flat());
257 const out: [string, string[]][] = headings
258 .sort()
259 .map((h) => [h, f.options.filter((o) => (f.groups[h] ?? []).includes(o))]);
260 const rest = f.options.filter((o) => !grouped.has(o));
261 if (rest.length) out.push(["other", rest]);
262 return out.filter(([, opts]) => opts.length > 0);
263}
264
265const isEmpty = (v: Draft[string] | undefined) =>
266 v === undefined || v === "" || (Array.isArray(v) && v.length === 0);
267
268/** Why the draft can't be submitted, one line per field in form order; [] when it can. The rules are werkmeister-core's validateFormData. */
269async function formErrors(
270 $: EngineInterface,
271 bundle: FormBundle,
272 fields: FormField[],
273 draft: Draft,
274): Promise<string[]> {
275 const errors = await core<Record<string, string>>($, "validate-form", {
276 bundle: { schema: bundle.schema },
277 formData: draft,
278 });
279 const known = fields.filter((f) => errors[f.key]).map((f) => `${f.title}: ${errors[f.key]}`);
280 return [
281 ...known,
282 ...Object.entries(errors)
283 .filter(([k]) => !fields.some((f) => f.key === k))
284 .map(([, message]) => message),
285 ];
286}
287
288/**
289 * Values from the workspace_form tool laid over the draft: a key the form doesn't
290 * have, a choice it doesn't offer or a value of the wrong shape is left out and
291 * named in `problems`. "" or [] clears a field; a multi-pick also takes "a, b".
292 */
293export function applyFormFields(
294 fields: FormField[],
295 draft: Draft,
296 input: Record<string, unknown>,
297): { draft: Draft; problems: string[] } {
298 const out = { ...draft };
299 const problems: string[] = [];
300 for (const [k, raw] of Object.entries(input)) {
301 const f = fields.find((x) => x.key === k);
302 if (!f) {
303 problems.push(`${k}: no such field (fields: ${fields.map((x) => x.key).join(", ")})`);
304 continue;
305 }
306 if (f.kind === "check") {
307 // The tool's input schema carries strings, so "true" / "false" arrive as text.
308 const on =
309 raw === true || raw === "true"
310 ? true
311 : raw === false || raw === "false" || raw === ""
312 ? false
313 : null;
314 if (on === null) problems.push(`${k}: expected true or false`);
315 else out[k] = on;
316 continue;
317 }
318 if (f.kind === "multi") {
319 const list =
320 typeof raw === "string"
321 ? raw
322 .split(",")
323 .map((s) => s.trim())
324 .filter(Boolean)
325 : raw;
326 if (!Array.isArray(list) || !list.every((v) => typeof v === "string")) {
327 problems.push(`${k}: expected a list of choices`);
328 continue;
329 }
330 const bad = list.filter((v) => !f.options.includes(v));
331 if (bad.length)
332 problems.push(`${k}: not offered: ${bad.join(", ")} (choices: ${f.options.join(", ")})`);
333 out[k] = list.filter((v) => f.options.includes(v));
334 continue;
335 }
336 if (typeof raw !== "string") {
337 problems.push(`${k}: expected text`);
338 continue;
339 }
340 if (f.kind === "select" && raw !== "" && !f.options.includes(raw)) {
341 problems.push(`${k}: "${raw}" is not offered (choices: ${f.options.join(", ")})`);
342 continue;
343 }
344 out[k] = raw;
345 }
346 return { draft: out, problems };
347}
348
349const FORM_TOOL = "workspace_form";
350
351/** The workspace_form tool: fill the Create tab from the prompt, then the person reviews and creates. */
352async function fillFormTool($: EngineInterface, input: Record<string, unknown>): Promise<string> {
353 await openPane($);
354 await openCreate($);
355 const { value: form = null } = await $.state.get(FORM);
356 if (!form) {
357 const { value: note = "" } = await $.state.get(NOTE);
358 return note || "Couldn't load the workspace form.";
359 }
360 const fields = formFields(form.bundle);
361 const { value: draft = {} } = await $.state.get(DRAFT);
362 const given =
363 input.fields && typeof input.fields === "object" && !Array.isArray(input.fields)
364 ? (input.fields as Record<string, unknown>)
365 : {};
366 const r = applyFormFields(fields, draft, given);
367 await $.state.set(DRAFT, r.draft);
368 const missing = await formErrors($, form.bundle, fields, r.draft);
369 return [
370 `The Create tab of the /workspaces pane now holds:`,
371 JSON.stringify(r.draft, null, 2),
372 r.problems.length ? `Left out:\n- ${r.problems.join("\n- ")}` : "",
373 missing.length
374 ? `Still needed before Create:\n- ${missing.join("\n- ")}`
375 : "Ready: the person presses + Create workspace in the pane to create it.",
376 "This tool never creates the workspace.",
377 ]
378 .filter(Boolean)
379 .join("\n\n");
380}
381
382async function openCreate($: EngineInterface): Promise<void> {
383 await $.state.set(TAB, "create");
384 const { value: form = null } = await $.state.get(FORM);
385 if (form) return;
386 try {
387 const r = await core<{ bundle: FormBundle; trackerLabel: string }>($, "form-schema");
388 await $.state.set(FORM, { bundle: r.bundle, trackerLabel: r.trackerLabel });
389 await $.state.set(DRAFT, r.bundle.formData ?? {});
390 } catch (err) {
391 await $.state.set(NOTE, `Couldn't load the workspace form: ${(err as Error).message}`);
392 }
393}
394
395async function setField($: EngineInterface, key: string, value: Draft[string]): Promise<void> {
396 const { value: draft = {} } = await $.state.get(DRAFT);
397 await $.state.set(DRAFT, { ...draft, [key]: value });
398}
399
400async function fetchIssue($: EngineInterface): Promise<void> {
401 const { value: draft = {} } = await $.state.get(DRAFT);
402 const ref = typeof draft.issue_id === "string" ? draft.issue_id.trim() : "";
403 if (!ref) return;
404 await $.state.set(FORM_BUSY, "fetch");
405 try {
406 // The tracker lookup runs in werkmeister-core; the distillation runs on this session's own client and login.
407 const req = await core<{ base: Record<string, unknown>; prompt: string | null }>(
408 $,
409 "issue-form-request",
410 { ref },
411 180000,
412 );
413 if (!req.base.issue_id) {
414 await $.state.set(NOTE, `Couldn't read an issue from "${ref}".`);
415 return;
416 }
417 let data = req.base;
418 let failed = "";
419 if (req.prompt) {
420 const r = await $.model.complete({ model: REFINE_MODEL, prompt: req.prompt });
421 if (r.isAnswered)
422 data = await core<Record<string, unknown>>($, "issue-form-apply", {
423 base: req.base,
424 answer: r.text,
425 });
426 else failed = ` Couldn't distil the rest (${r.reason}).`;
427 }
428 const { value: now = {} } = await $.state.get(DRAFT);
429 const merged = await core<Draft>($, "merge-form", { current: now, incoming: data });
430 const filled = Object.keys(data).filter(
431 (k) => k !== "issue_id" && isEmpty(now[k]) && !isEmpty(merged[k]),
432 );
433 await $.state.set(DRAFT, merged);
434 await $.state.set(
435 NOTE,
436 (filled.length
437 ? `Filled ${filled.join(", ")} from ${data.issue_id}.`
438 : `${data.issue_id}: nothing new to fill.`) + failed,
439 );
440 } catch (err) {
441 await $.state.set(NOTE, (err as Error).message);
442 } finally {
443 await $.state.set(FORM_BUSY, "");
444 }
445}
446
447/** The model behind Refine with Claude and the issue fetch's distillation. */
448export const REFINE_MODEL = "sonnet";
449
450/**
451 * "Refine with Claude": rewrite the field with werkmeister-core's refine prompt, the issue and the other fields as context.
452 * The completion runs on this session's own client and login, not a `claude -p` child with credentials of its own.
453 */
454async function refineField($: EngineInterface, key: string, instruction: string): Promise<void> {
455 const { value: draft = {} } = await $.state.get(DRAFT);
456 const text = typeof draft[key] === "string" ? draft[key].trim() : "";
457 if (!text) {
458 await $.state.set(NOTE, "Write something first; Refine rewrites what is there.");
459 return;
460 }
461 await $.state.set(FORM_BUSY, "refine");
462 try {
463 const formData = Object.fromEntries(Object.entries(draft).filter(([, v]) => !isEmpty(v)));
464 const prompt = await core<string>($, "refine-request", { text, instruction, formData });
465 const r = await $.model.complete({ model: REFINE_MODEL, prompt });
466 if (!r.isAnswered) {
467 await $.state.set(NOTE, `Refine failed (${r.reason}); the field is unchanged.`);
468 return;
469 }
470 // Same extraction as werkmeister-core's extractResult.
471 const refined = (r.text.match(/<result>\s*([\s\S]*?)\s*<\/result>/i)?.[1] ?? r.text).trim();
472 if (!refined) {
473 await $.state.set(NOTE, "Refine returned no text; the field is unchanged.");
474 return;
475 }
476 await setField($, key, refined);
477 await $.state.set(NOTE, "Refined. Edit it, or Refine again.");
478 } catch (err) {
479 await $.state.set(NOTE, (err as Error).message);
480 } finally {
481 await $.state.set(FORM_BUSY, "");
482 }
483}
484
485async function submitForm($: EngineInterface): Promise<void> {
486 const { value: form = null } = await $.state.get(FORM);
487 const { value: first = {} } = await $.state.get(DRAFT);
488 if (!form) return;
489 // Core stores the issue but doesn't run a model for the pane: fill empty fields on the session model first.
490 if (
491 typeof first.issue_id === "string" &&
492 first.issue_id.trim() &&
493 (isEmpty(first.description) || isEmpty(first.starting_prompt))
494 ) {
495 await fetchIssue($);
496 // What the model filled is a default to review, not something to create from unseen.
497 const { value: filled = {} } = await $.state.get(DRAFT);
498 // The ref's own normalisation (api#7 → acme/api#7) is not a fill.
499 if (
500 Object.keys(filled).some(
501 (k) => k !== "issue_id" && JSON.stringify(filled[k]) !== JSON.stringify(first[k]),
502 )
503 ) {
504 const { value: note = "" } = await $.state.get(NOTE);
505 await $.state.set(NOTE, `${note} Review, then press Create again.`);
506 return;
507 }
508 }
509 const { value: draft = {} } = await $.state.get(DRAFT);
510 const errors = await formErrors($, form.bundle, formFields(form.bundle), draft);
511 if (errors.length) {
512 await $.state.set(NOTE, errors.join("\n"));
513 return;
514 }
515 const formData = Object.fromEntries(Object.entries(draft).filter(([, v]) => !isEmpty(v)));
516 await $.state.set(FORM_BUSY, "create");
517 await $.state.set(NOTE, "Creating the workspace…");
518 try {
519 const r = await core<{ command: string; workspacePath: string | null }>(
520 $,
521 "create-workspace",
522 { formData, distill: false },
523 600000,
524 );
525 await $.state.set(DRAFT, {});
526 await $.state.set(TAB, "list");
527 await $.state.set(NOTE, `Created ${r.workspacePath ?? r.command}`);
528 // A failed open must not read as a failed create: the workspace exists either way.
529 const opened =
530 r.workspacePath !== null &&
531 (await core($, "open-claude-link", { url: newSessionLink(r.workspacePath) }).then(
532 () => true,
533 () => false,
534 ));
535 $.ui.toast(
536 opened
537 ? "Workspace created; opening a new session in it"
538 : "Workspace created; open it with + New session",
539 );
540 } catch (err) {
541 // The transcript can run long; its last lines carry the failure.
542 await $.state.set(NOTE, (err as Error).message.split("\n").slice(-6).join("\n"));
543 } finally {
544 await $.state.set(FORM_BUSY, "");
545 }
546 await tick($);
547}
548
549type ListEntry = Pick<
550 WorkspaceRow,
551 | "name"
552 | "path"
553 | "issueId"
554 | "issueUrl"
555 | "labels"
556 | "task"
557 | "description"
558 | "workType"
559 | "stage"
560 | "lifecycle"
561 | "sessionTitle"
562 | "sessionOrigin"
563 | "feature"
564>;
565type ListResult = { workspaces: ListEntry[] };
566type StatusResult = {
567 infra?: InfraStatus;
568 statuses: {
569 name: string;
570 state: WorkspaceRow["state"];
571 running: number;
572 total: number;
573 services: ServiceHealth[];
574 claude: {
575 state: string;
576 agent: "working" | "needs-you" | "done" | "idle";
577 at?: string | null;
578 question?: string | null;
579 };
580 phase?: string | null;
581 autonomous?: boolean;
582 lastActivity?: string | null;
583 project?: string | null;
584 started?: WorkspaceRow["started"];
585 crashLoops?: WorkspaceRow["crashLoops"];
586 paused?: WorkspaceRow["paused"];
587 resumeNudge?: boolean;
588 }[];
589};
590type SessionInfo = {
591 sessionId: string;
592 title: string;
593 cwd: string;
594 link?: string;
595 startedBy?: string;
596 pinned?: boolean;
597};
598type ToolResult = {
599 content: { type: string; text?: string }[];
600 isError: boolean;
601 structuredContent?: unknown;
602};
603
604/** The tool's structured result, or its first text block parsed as JSON (ccd tools return JSON text). */
605function payload<T>(r: ToolResult): T {
606 if (r.isError)
607 throw new Error(
608 r.content
609 .map((c) => c.text ?? "")
610 .join(" ")
611 .trim() || "tool failed",
612 );
613 if (r.structuredContent !== undefined) return r.structuredContent as T;
614 const text = r.content.find((c) => c.type === "text")?.text ?? "";
615 return JSON.parse(text) as T;
616}
617
618/** True when `child` is `parent` or inside it, by path segment: /ws/ab is not an ancestor of /ws/abc. */
619function contains(parent: string, child: string): boolean {
620 return child === parent || child.startsWith(parent.endsWith("/") ? parent : parent + "/");
621}
622
623/** The newest session whose cwd lies in `path`, and in no deeper workspace. Sessions arrive newest first. */
624export function sessionFor(
625 path: string,
626 allPaths: string[],
627 sessions: SessionInfo[],
628): WorkspaceRow["session"] {
629 // list_sessions leaves out the calling session, so a startedBy naming no listed session is this one.
630 // ponytail: a starter archived or past the list limit reads as this session too; the split then refuses and says so.
631 const listed = new Set(sessions.map((s) => s.sessionId));
632 for (const s of sessions) {
633 if (!contains(path, s.cwd)) continue;
634 const deeper = allPaths.some(
635 (p) => p !== path && p.length > path.length && contains(path, p) && contains(p, s.cwd),
636 );
637 if (deeper) continue;
638 return {
639 id: s.sessionId,
640 title: s.title,
641 link: s.link ?? null,
642 beside: !!s.startedBy && !listed.has(s.startedBy),
643 pinned: s.pinned === true,
644 };
645 }
646 return null;
647}
648
649export function buildRows(
650 list: ListResult,
651 status: StatusResult,
652 sessions: SessionInfo[],
653): WorkspaceRow[] {
654 const byName = new Map(status.statuses.map((s) => [s.name, s]));
655 const allPaths = list.workspaces.map((w) => w.path);
656 return list.workspaces.map((w) => {
657 const s = byName.get(w.name);
658 return {
659 ...w,
660 state: s?.state ?? "unknown",
661 running: s?.running ?? 0,
662 total: s?.total ?? 0,
663 services: s?.services ?? [],
664 claude: {
665 agent: s?.claude.agent ?? "idle",
666 at: s?.claude.at ?? null,
667 question: s?.claude.question ?? null,
668 },
669 phase: s?.phase ?? null,
670 autonomous: s?.autonomous ?? false,
671 lastActivity: s?.lastActivity ?? null,
672 project: s?.project ?? null,
673 started: s?.started ?? null,
674 crashLoops: s?.crashLoops ?? [],
675 paused: s?.paused ?? null,
676 resumeNudge: s?.resumeNudge ?? false,
677 session: sessionFor(w.path, allPaths, sessions),
678 };
679 });
680}
681
682/** With the toggle on, only workspaces labelled `focus` (in `.sandbox/workspace.yml`) are listed. */
683export function visibleRows(rows: WorkspaceRow[], focus: boolean): WorkspaceRow[] {
684 return focus ? rows.filter((r) => r.labels.includes("focus")) : rows;
685}
686
687/** PR file statuses that say more than gh can: shipped is merged and released, abandoned is the workspace's call. */
688const PAST_GH = new Set(["shipped", "abandoned"]);
689
690/**
691 * The row with each PR file's status replaced by the PR poll's live state for its repo, and the lifecycle state
692 * recomputed as werkmeister-core's `lifecycleOf` does, so the meta line and buttons never contradict the card's
693 * `⑂` PR line (SW-50). A repo the poll has no PR for, or no poll yet, keeps its file status.
694 */
695export function withLivePrs(r: WorkspaceRow, live: RepoPr[]): WorkspaceRow {
696 const l = r.lifecycle;
697 if (!l || l.state === "closed-out") return r;
698 const prs = l.prs.map((p) => {
699 const state = live.find((x) => x.repo === p.dir)?.state?.toLowerCase();
700 return state && state !== p.status && !PAST_GH.has(p.status) ? { ...p, status: state } : p;
701 });
702 if (prs.every((p, i) => p === l.prs[i])) return r;
703 const all = (s: string[]) => prs.every((p) => s.includes(p.status));
704 const state = all(["abandoned"])
705 ? "abandoned"
706 : all(["shipped", "closed", "abandoned"])
707 ? "shipped"
708 : all(["merged", "shipped", "closed", "abandoned"])
709 ? "merged"
710 : prs.some((p) => p.status === "open")
711 ? "pr-open"
712 : all(["approved"])
713 ? "ready"
714 : "working";
715 return { ...r, lifecycle: { state, prs } };
716}
717
718/** The display model's tones in the pane's named colours: the pane has no solid amber, so Needs you is bold warning. */
719const TONE: Record<Tone, Color> = {
720 ok: "success",
721 warn: "warning",
722 err: "error",
723 off: "inactive",
724 pr: "merged",
725 needs: "warning",
726};
727
728/** The shared display model (docs/ui-spec.md § The card) for one row; `prs` null when the PR poll failed. */
729export function display(
730 r: WorkspaceRow,
731 prs: RepoPr[] | null,
732 now: Date,
733 resources: WorkspaceResources | null = null,
734): WorkspaceDisplay {
735 return displayModel(r, {
736 // Not `prs?.map`: null (lookup failed) must reach the model as null, not as undefined (no PRs).
737 prs:
738 prs === null
739 ? null
740 : prs.map((p) => ({
741 url: p.url,
742 state: p.state,
743 ci: p.ci ?? null,
744 failingChecks: p.failingChecks ?? 0,
745 })),
746 lifecycle: r.lifecycle?.state ?? null,
747 now,
748 usage: r.project ? resources?.projects[r.project] : null,
749 });
750}
751
752/** The card's resources line, `12.3% CPU · 410 MiB · started 2 h ago by up`; '' when neither is known. */
753export function resourcesLine(d: WorkspaceDisplay): string {
754 return [d.usage?.text, d.started?.text].filter(Boolean).join(" · ");
755}
756
757/** One run of the compact status line: `key` parts are the dim labels, `href` makes it a link. */
758export type Part = { text: string; tone?: Tone; key?: true; href?: string };
759
760/**
761 * The compact line, always containers · PRs · workflow (ui-spec § The card → Compact). `loaded` is false
762 * before the first PR poll, which would otherwise read as `None yet`.
763 */
764export function metaParts(d: WorkspaceDisplay, loaded: boolean): Part[] {
765 const sep: Part = { text: " · ", key: true };
766 const prs: Part[] =
767 d.prsEmpty?.tone === "warn"
768 ? [{ text: `${d.prsEmpty.text} · retry`, tone: "warn" }]
769 : [
770 { text: "PRs ", key: true },
771 ...(!loaded
772 ? [{ text: "loading…", key: true } as const]
773 : d.prsEmpty
774 ? [{ text: d.prsEmpty.text, tone: d.prsEmpty.tone }]
775 : d.prs.flatMap((p, i): Part[] => [
776 ...(i ? [{ text: ", ", key: true } as const] : []),
777 { text: p.ref, href: p.url },
778 { text: ` ${p.state.text}`, tone: p.state.tone },
779 ...(p.ci ? [sep, { text: p.ci.text, tone: p.ci.tone }] : []),
780 ])),
781 ];
782 return [
783 { text: "containers ", key: true },
784 { text: d.containers.text, tone: d.containers.tone },
785 sep,
786 ...prs,
787 ...(d.workflow.kind === "none" ? [] : [sep, { text: d.workflow.compact }]),
788 ];
789}
790
791/** The subtitle: work type · task title from workspace.yml, never the session title (SW-52). */
792export function subtitle(r: WorkspaceRow): string {
793 const type = r.workType
794 ? (WORK_TYPE[r.workType] ?? r.workType.charAt(0).toUpperCase() + r.workType.slice(1))
795 : null;
796 return [type, r.task].filter(Boolean).join(" · ");
797}
798
799/** The design's type tags (Bug, Feature, …); the wizard's `bugfix` reads as Bug. */
800const WORK_TYPE: Record<string, string> = { bugfix: "Bug" };
801
802/** A session Continue can pick up: one the app lists, or the newest on disk. */
803function hasSession(r: WorkspaceRow): boolean {
804 return r.session !== null || r.sessionTitle !== null;
805}
806
807/**
808 * Where Continue picks the session up, named on its button: Desktop when the app lists it
809 * (the pane focuses or opens it), VS Code for a VS Code session (the folder opens there),
810 * else a Terminal window running `claude --resume`.
811 */
812export function continueTarget(r: WorkspaceRow): "Desktop" | "VS Code" | "Terminal" {
813 if (r.session) return "Desktop";
814 return r.sessionOrigin === "claude-vscode" ? "VS Code" : "Terminal";
815}
816
817/** `claude://code/new?folder=…`: the link the app's "New Claude Code Session Here" Finder service opens. */
818export function newSessionLink(path: string): string {
819 return `claude://code/new?folder=${encodeURIComponent(path)}&source=services`;
820}
821
822/** The infra line: `infra partial · 4 of 5 up · down: caddy`; services not healthy or running are named. */
823export function infraLine(
824 i: InfraStatus | null,
825 t: { ok: boolean; reason?: string; fix: string } | null = null,
826): string {
827 const tracker = t && !t.ok ? `tracker: ${t.reason} · ${t.fix}` : null;
828 if (!i) return ["infra · unknown", tracker].filter(Boolean).join(" · ");
829 const bad = i.services
830 .filter((s) => s.state !== "healthy" && s.state !== "running")
831 .map((s) => `${s.state}: ${s.name}`);
832 return [`infra ${i.state}`, i.total > 0 ? `${i.running} of ${i.total} up` : null, ...bad, tracker]
833 .filter(Boolean)
834 .join(" · ");
835}
836
837const INFRA_COLOR: Record<InfraStatus["state"], "success" | "warning" | "error"> = {
838 up: "success",
839 partial: "warning",
840 down: "error",
841 unknown: "warning",
842};
843
844/** `sandbox connect`: brings up the shared infra, then refreshes so the infra line shows the result. */
845async function connect($: EngineInterface): Promise<void> {
846 const { value: busy = "" } = await $.state.get(BUSY);
847 if (busy !== "") return;
848 await $.state.set(BUSY, CONNECTING);
849 await $.state.set(NOTE, "sandbox connect…");
850 try {
851 const r = await core<{ ok: boolean }>($, "connect", {}, 600000);
852 await $.state.set(
853 NOTE,
854 r.ok
855 ? "sandbox connect: done"
856 : "sandbox connect: some infra failed to start; check docker and your kubeconfig",
857 );
858 } catch (err) {
859 await $.state.set(NOTE, (err as Error).message);
860 } finally {
861 await $.state.set(BUSY, "");
862 }
863 await tick($);
864}
865
866/**
867 * Sends Resume's "continue" (SW-70) to each workspace's Desktop session: only the pane can name it. The claim
868 * removes the file first, so a failure is logged, not retried; a row with no Desktop session leaves it be.
869 */
870async function deliverNudges($: EngineInterface, rows: WorkspaceRow[]): Promise<void> {
871 const due = rows.filter((r) => r.resumeNudge && r.session);
872 if (!due.length) return;
873 const self = payload<{ sessionId: string }>(
874 await $.mcp.call("ccd_session_mgmt", "get_session", { session_id: "self" }),
875 ).sessionId;
876 for (const r of due) {
877 try {
878 const { message } = await core<{ message: string | null }>($, "claim-resume-nudge", {
879 name: r.name,
880 });
881 if (!message || !r.session) continue;
882 // send_message refuses the calling session, so a nudge for this one is its own prompt.
883 if (r.session.id === self) await $.prompt.submit({ text: message });
884 else {
885 const sent = await $.mcp.call("ccd_session_mgmt", "send_message", {
886 session_id: r.session.id,
887 message,
888 });
889 const text = sent.content.map((c) => c.text ?? "").join(" ");
890 if (sent.isError || !/delivery: (delivered|queued)/.test(text)) throw new Error(text);
891 }
892 } catch (e) {
893 $.ui.log(`Resume "continue" not delivered to ${r.name}: ${(e as Error).message}`);
894 }
895 }
896}
897
898let ticking = false;
899/** Refreshes rows once. A tick that starts while one runs is skipped. */
900async function tick($: EngineInterface): Promise<void> {
901 if (ticking) return;
902 ticking = true;
903 try {
904 // Kept out of the Promise.all: a failed or slow telepresence read must never cost the rows.
905 const intercepts = core<InterceptsSnapshot>($, "intercepts", {}, 20000).catch(() => null);
906 const [list, status, sessionsR] = await Promise.all([
907 core<ListResult>($, "list-workspaces"),
908 core<StatusResult>($, "workspace-status"),
909 $.mcp.call("ccd_session_mgmt", "list_sessions", { limit: 50 }).catch(() => null),
910 ]);
911 let sessions: SessionInfo[] = [];
912 try {
913 sessions = sessionsR ? payload<SessionInfo[]>(sessionsR) : [];
914 } catch {
915 sessions = [];
916 }
917 const rows = buildRows(list, status, sessions);
918 await $.state.set(ROWS, rows);
919 await deliverNudges($, rows);
920 await $.state.set(INFRA, status.infra ?? null);
921 await $.state.set(INTERCEPTS, await intercepts);
922 await $.state.set(ERROR, "");
923 if (Date.now() - trackerAt > TRACKER_TTL_MS) {
924 trackerAt = Date.now();
925 // Not awaited: a slow tracker check must not hold up row polling.
926 void core<{ ok: boolean; reason?: string; fix: string }>($, "tracker-auth").then(
927 (a) => $.state.set(TRACKER, a),
928 () => $.state.set(TRACKER, null),
929 );
930 }
931 } catch (err) {
932 await $.state.set(ERROR, (err as Error).message);
933 } finally {
934 ticking = false;
935 }
936}
937
938export type InterceptControl = {
939 kind: "off" | "active" | "other" | "blocked" | "unavailable";
940 text: string;
941};
942
943/**
944 * One service's toggle, or null for a local-only service (SW-21). The VS Code tree's
945 * `interceptView` rules; this standalone plugin can't import it.
946 */
947export function interceptControl(
948 service: string,
949 row: InterceptRow | undefined,
950 feature: string | null,
951 snap: InterceptsSnapshot | null,
952): InterceptControl | null {
953 // A failed lookup can't tell remote from local, so it keeps "unavailable" on every row.
954 if (snap === null) return { kind: "unavailable", text: "intercept state unavailable" };
955 if (!snap.remote_services.includes(service)) return null;
956 // Installed but disconnected falls through: Intercept connects first (SW-12).
957 if (!(snap.installed ?? snap.available))
958 return { kind: "unavailable", text: "intercepts unavailable" };
959 if (!row) return { kind: "blocked", text: "not an interceptable service of this workspace" };
960 if (row.intercepted) {
961 // One intercept per deployment: an unreadable owner is never offered a Leave.
962 if (feature !== null && row.intercepted_by === feature)
963 return { kind: "active", text: "⇄ Intercepted" };
964 return {
965 kind: "other",
966 text:
967 row.intercepted_by === null
968 ? "⇄ intercepted (owner unknown)"
969 : `⇄ intercepted by '${row.intercepted_by}'`,
970 };
971 }
972 if (!row.interceptable)
973 return { kind: "blocked", text: `not interceptable: ${row.blocker ?? "unknown reason"}` };
974 return { kind: "off", text: "" };
975}
976
977/** Intercept buttons. ON asks first, as it installs a traffic-agent sidecar on a shared dev cluster; OFF runs at once. */
978async function pressIntercept(
979 $: EngineInterface,
980 kind: "ask" | "yes" | "cancel" | "leave",
981 name: string,
982 service: string,
983): Promise<void> {
984 const { value: busy = "" } = await $.state.get(BUSY);
985 if (busy !== "") return;
986 if (kind === "ask") {
987 await $.state.set(CONFIRM, `${INTERCEPT}${name}/${service}`);
988 return;
989 }
990 await $.state.set(CONFIRM, "");
991 if (kind === "cancel") return;
992 const { value: rows = [] } = await $.state.get(ROWS);
993 const feature = rows.find((r) => r.name === name)?.feature;
994 if (!feature) return;
995 await $.state.set(BUSY, name);
996 await $.state.set(NOTE, `${service}: ${kind === "yes" ? "starting" : "leaving"} intercept…`);
997 try {
998 if (kind === "yes") {
999 await core($, "intercept-start", { name, feature, service, dockerDaemon: true }, 180000);
1000 await $.state.set(NOTE, `${service}: intercepted (x-feature: ${feature})`);
1001 } else {
1002 await core($, "intercept-leave", { name, feature, service, dockerDaemon: true }, 60000);
1003 await $.state.set(NOTE, `${service}: left intercept`);
1004 }
1005 } catch (err) {
1006 await $.state.set(NOTE, (err as Error).message);
1007 } finally {
1008 await $.state.set(BUSY, "");
1009 }
1010 await tick($);
1011}
1012
1013export type Action =
1014 | "up"
1015 | "stop"
1016 | "restart"
1017 | "down"
1018 | "pause"
1019 | "resume"
1020 | "new"
1021 | "continue"
1022 | "beside"
1023 | "pin"
1024 | "prs"
1025 | "closeout"
1026 | "abandon"
1027 | "cancel";
1028type Lifecycle = "up" | "stop" | "restart" | "down" | "pause" | "resume";
1029const LIFECYCLE = new Set<Action>(["up", "stop", "restart", "down", "pause", "resume"]);
1030
1031const LABEL: Record<Exclude<Action, "continue" | "pin">, string> = {
1032 up: "▶ Up",
1033 stop: "■ Stop",
1034 restart: "↻ Restart",
1035 down: "⏻ Down",
1036 pause: "⏸ Pause",
1037 resume: "▶ Resume",
1038 new: "+ New session",
1039 beside: "⇆ Open beside",
1040 prs: "↻ Refresh PRs",
1041 closeout: "✓ Close out",
1042 abandon: "✕ Abandon…",
1043 cancel: "Cancel",
1044};
1045
1046/** What a card offers, in the order the bar and the ⋯ menu draw it; `issue` is the Open issue link. */
1047export type CardActions = { primary: Action; bar: Action[]; menu: (Action | "issue")[] };
1048
1049/** The ⋯ menu's leftover lifecycle and session buttons, in this order (06 Actions). */
1050const EXTRAS: Action[] = ["continue", "stop", "pause", "restart", "down", "new"];
1051
1052/**
1053 * ui-spec § Actions: the display model's primary, two secondaries, and everything else in ⋯ in the spec's order.
1054 * The secondaries follow the containers (06 Actions): Up and New while nothing runs, Stop and Down while paused,
1055 * else Stop and Pause, with Restart and Down once the work shipped; when the primary is one of them, Continue
1056 * takes its slot.
1057 */
1058export function actionsFor(r: WorkspaceRow, d: WorkspaceDisplay, canType: boolean): CardActions {
1059 const session = hasSession(r);
1060 const paused = r.paused != null;
1061 const off = r.state === "down" || r.state === "stopped";
1062 const shipped = r.lifecycle?.state === "shipped";
1063 const want: Action = d.primary.action === "close-out" ? "closeout" : d.primary.action;
1064 // Continue with no session would resume nothing: New session takes its place.
1065 const primary: Action = want === "continue" && !session ? "new" : want;
1066 const pair: Action[] = paused
1067 ? ["stop", "down"]
1068 : off
1069 ? ["up", "new"]
1070 : shipped
1071 ? ["restart", "down"]
1072 : ["stop", "pause"];
1073 const bar = pair.flatMap((a): Action[] => (a !== primary ? [a] : session ? ["continue"] : []));
1074 const allowed = (a: Action) =>
1075 a === "continue"
1076 ? session
1077 : paused
1078 ? a === "new" || a === "stop" || a === "down"
1079 : off
1080 ? a === "new" || (a === "down" && r.state === "stopped")
1081 : !(shipped && a === "stop");
1082 return {
1083 primary,
1084 bar,
1085 menu: [
1086 ...EXTRAS.filter((a) => allowed(a) && a !== primary && !bar.includes(a)),
1087 ...(r.session?.beside ? (["beside"] as const) : []),
1088 ...(r.session ? (["pin"] as const) : []),
1089 "prs",
1090 ...(r.issueUrl ? (["issue"] as const) : []),
1091 ...(canCloseOut(r) && primary !== "closeout" ? (["closeout"] as const) : []),
1092 // Abandon needs a typed reason, so none where no Input draws.
1093 ...(canType ? (["abandon"] as const) : []),
1094 ],
1095 };
1096}
1097
1098/** Close out applies (#160) once every PR settled, to a spike with none, or as a retry after a failed removal. */
1099export function canCloseOut(r: WorkspaceRow): boolean {
1100 const s = r.lifecycle?.state;
1101 return (
1102 s === "shipped" ||
1103 s === "abandoned" ||
1104 s === "closed-out" ||
1105 (r.workType === "spike" && !r.lifecycle?.prs.length)
1106 );
1107}
1108
1109/** CONFIRM holds `close-out:<name>` or `abandon:<name>` between the two presses. */
1110const CLOSE_OUT = "close-out:";
1111const ABANDON = "abandon:";
1112
1113/**
1114 * Archives every desktop session whose cwd is in `path`, one line each. A refusal (a pinned or on-screen
1115 * session, a declined approval) is reported, never fatal: the workspace is already gone. list_sessions
1116 * leaves out the calling session, so it is never archived from under itself.
1117 */
1118export async function archiveSessions($: EngineInterface, path: string): Promise<string[]> {
1119 let sessions: SessionInfo[];
1120 try {
1121 sessions = payload<SessionInfo[]>(
1122 await $.mcp.call("ccd_session_mgmt", "list_sessions", { limit: 200 }),
1123 );
1124 } catch (err) {
1125 return [`desktop sessions not archived: ${(err as Error).message}`];
1126 }
1127 const out: string[] = [];
1128 for (const s of sessions.filter((x) => contains(path, x.cwd))) {
1129 try {
1130 const r = await $.mcp.call("ccd_session_mgmt", "archive_session", {
1131 session_id: s.sessionId,
1132 });
1133 out.push(
1134 r.isError
1135 ? `left open "${s.title}": ${r.content
1136 .map((c) => c.text ?? "")
1137 .join(" ")
1138 .trim()}`
1139 : `archived "${s.title}"`,
1140 );
1141 } catch (err) {
1142 out.push(`left open "${s.title}": ${(err as Error).message}`);
1143 }
1144 }
1145 return out;
1146}
1147
1148async function runAction(
1149 $: EngineInterface,
1150 name: string,
1151 action: Lifecycle | "prs" | "closeout" | "abandon",
1152): Promise<void> {
1153 await $.state.set(BUSY, name);
1154 await $.state.set(NOTE, `${name}: ${action}…`);
1155 try {
1156 const { value: rows = [] } = await $.state.get(ROWS);
1157 const path = rows.find((x) => x.name === name)?.path;
1158 if (action === "closeout" || action === "abandon") {
1159 const { value: reason = "" } = await $.state.get(REASON);
1160 const r = await core<{ command: string; terminals?: string[] }>(
1161 $,
1162 "close-out",
1163 action === "abandon" ? { name, abandon: reason } : { name },
1164 600000,
1165 );
1166 const archived = path ? await archiveSessions($, path) : [];
1167 await $.state.set(
1168 NOTE,
1169 [r.command.split("\n")[0]?.trim() || "done", ...archived, ...(r.terminals ?? [])].join(
1170 "\n",
1171 ),
1172 );
1173 await $.state.set(REASON, "");
1174 } else {
1175 const r =
1176 action === "prs"
1177 ? await core<{ command: string }>($, "pr-status", { path }, 120000)
1178 : await core<{ command: string; output: string }>(
1179 $,
1180 "workspace-action",
1181 { name, action },
1182 600000,
1183 );
1184 await $.state.set(NOTE, r.command.split("\n")[0]?.trim() || "done");
1185 // ↻ Refresh PRs is also the retry for `PRs unavailable`.
1186 if (action === "prs") await prTick($);
1187 }
1188 } catch (err) {
1189 await $.state.set(NOTE, (err as Error).message);
1190 } finally {
1191 await $.state.set(BUSY, "");
1192 }
1193 await tick($);
1194}
1195
1196/** Every row button lands here. A press while any action runs is ignored, never queued. */
1197async function press($: EngineInterface, action: Action, name: string): Promise<void> {
1198 const { value: busy = "" } = await $.state.get(BUSY);
1199 if (busy !== "") return;
1200hooks/display-model.ts 344 lines1/**
2 * A copy of packages/werkmeister-ui/src/display-model.ts (SW-49): the plugin loader refuses imports from outside
3 * this folder. Everything from the `Tone` doc comment down is byte-identical to that file, and
4 * packages/werkmeister-ui/test/pane-copy.test.mjs fails when it drifts; change the original, then copy it here.
5 * The types above that line stand in for its `werkmeister-core` imports.
6 */
7
8type AgentStateOf = "working" | "needs-you" | "done" | "idle";
9type ClaudeSessionSignal = { agent: AgentStateOf; at: string | null; question: string | null };
10type IcmStage = { index: number; total: number; name: string };
11type Lifecycle =
12 | "working"
13 | "ready"
14 | "pr-open"
15 | "merged"
16 | "shipped"
17 | "closed-out"
18 | "abandoned";
19type RepoPr = {
20 state: "OPEN" | "MERGED" | "CLOSED" | null;
21 ci: "pass" | "running" | "fail" | null;
22};
23type WorkspaceStatus = {
24 state: "up" | "partial" | "stopped" | "paused" | "down" | "unknown";
25 services: { name: string; state: "healthy" | "starting" | "unhealthy" | "running" | "down" }[];
26};
27
28/** `needs` is the solid-amber chip, used only for Needs you. */
29export type Tone = "ok" | "warn" | "err" | "off" | "pr" | "needs";
30
31export type AgentState = ClaudeSessionSignal["agent"];
32export type ServiceHealth = WorkspaceStatus["services"][number]["state"];
33
34/** The fields read from a `WorkspaceStatus`; a full one satisfies it. */
35export interface DisplayInput {
36 state: WorkspaceStatus["state"];
37 running: number;
38 total: number;
39 services: { name: string; state: ServiceHealth }[];
40 claude: Pick<ClaudeSessionSignal, "agent" | "at" | "question">;
41 stage: IcmStage | null;
42 phase: string | null;
43 autonomous: boolean;
44 lastActivity: string | null;
45 /** SW-41; optional so a core older than it still renders. */
46 started?: { at: string; by: string } | null;
47 /** SW-70; optional so a core older than it still renders. */
48 paused?: { at: string; containers: string[] } | null;
49 crashLoops?: { service: string; restarts: number; lastLog: string | null }[];
50}
51
52/** One project's share of the stats sweep (core `Usage`). */
53export interface UsageInput {
54 cpuPercent: number;
55 memMib: number;
56}
57
58/** Core `HostSummary`. */
59export interface HostInput {
60 load: number[];
61 cores: number;
62 featureCpuPercent: number;
63}
64
65/** A `RepoPr` satisfies it. Core reports neither drafts nor releases yet, so callers that know pass them. */
66export interface PrInput {
67 url: string | null;
68 state: RepoPr["state"];
69 ci: RepoPr["ci"];
70 failingChecks: number;
71 draft?: boolean;
72 /** The release tag (`v0.14.0`) once the merged PR shipped. */
73 release?: string | null;
74}
75
76export interface DisplayOptions {
77 /** null when the PR lookup failed; omitted when the workspace has none. */
78 prs?: PrInput[] | null;
79 lifecycle?: Lifecycle | null;
80 /** Where the newest session runs, for `↗ Continue in …`. */
81 where?: "Desktop" | "Terminal";
82 now?: Date;
83 /** From the slower resources poll; omitted or null until one lands. */
84 usage?: UsageInput | null;
85}
86
87export interface Text {
88 text: string;
89 tone: Tone;
90}
91
92export interface PrDisplay {
93 /** `repo#N`, the owner dropped (ui-spec § Badges). */
94 ref: string;
95 url: string;
96 state: Text & { dashed: boolean };
97 ci: Text | null;
98}
99
100export interface WorkspaceDisplay {
101 agent: {
102 glyph: string;
103 word: string;
104 label: string;
105 /** The header chip. */
106 tone: Tone;
107 /** The Agent row's dot; amber, not solid, for Needs you. */
108 dot: Tone;
109 codicon: string;
110 time: string;
111 };
112 needs: boolean;
113 question: string | null;
114 autonomous: { full: string; compact: string } | null;
115 containers: Text;
116 services: (Text & { name: string; glyph: string; codicon: string })[];
117 /** `12.3% CPU · 410 MiB`. */
118 usage: Text | null;
119 /** `started 2 h ago by gate`. */
120 started: Text | null;
121 /** `✗ api crash-looping · 4 restarts · <last log line>`, one per service joined by `; `. */
122 crash: Text | null;
123 /** The PRs row when there is no PR to list: `None yet` or `PRs unavailable`. */
124 prsEmpty: Text | null;
125 prs: PrDisplay[];
126 workflow: {
127 kind: "lifecycle" | "icm" | "jit" | "none";
128 text: string;
129 compact: string;
130 pips: ("done" | "current" | "next")[];
131 };
132 primary: {
133 action: "continue" | "close-out" | "up" | "resume";
134 label: string;
135 destructive: boolean;
136 };
137}
138
139const AGENT: Record<
140 AgentState,
141 { glyph: string; word: string; tone: Tone; dot: Tone; codicon: string }
142> = {
143 "needs-you": {
144 glyph: "●",
145 word: "Needs you",
146 tone: "needs",
147 dot: "warn",
148 codicon: "circle-filled",
149 },
150 working: { glyph: "◌", word: "Working", tone: "ok", dot: "ok", codicon: "sync" },
151 done: { glyph: "✓", word: "Done", tone: "ok", dot: "ok", codicon: "pass" },
152 idle: { glyph: "", word: "Idle", tone: "off", dot: "off", codicon: "circle-outline" },
153};
154
155/** Overrides every agent state while Pause holds the session: its last state is not news. */
156const PAUSED = {
157 glyph: "⏸",
158 word: "Paused",
159 tone: "off",
160 dot: "off",
161 codicon: "debug-pause",
162} as const;
163
164const SERVICE: Record<ServiceHealth, { glyph: string; tone: Tone; word: string; codicon: string }> =
165 {
166 healthy: { glyph: "●", tone: "ok", word: "", codicon: "circle-filled" },
167 running: { glyph: "●", tone: "ok", word: "", codicon: "circle-filled" },
168 down: { glyph: "○", tone: "off", word: "", codicon: "circle-outline" },
169 starting: { glyph: "◐", tone: "warn", word: " starting", codicon: "loading" },
170 unhealthy: { glyph: "▲", tone: "err", word: " unhealthy", codicon: "warning" },
171 };
172
173const CRASH_LOOP = { glyph: "✗", tone: "err", word: " crash-looping", codicon: "error" } as const;
174
175const LIFECYCLE: Partial<Record<Lifecycle, string>> = {
176 ready: "Ready to open PR",
177 merged: "Awaiting release",
178 shipped: "Ready to close out",
179 "closed-out": "Closed out",
180 abandoned: "Abandoned",
181};
182
183/** `12 s ago` · `12 min ago` · `5 h ago` · `2 days ago`; '' with no timestamp. */
184export function ago(iso: string | null, now: Date): string {
185 if (!iso) return "";
186 const s = Math.max(0, Math.round((now.getTime() - Date.parse(iso)) / 1000));
187 if (s < 60) return `${s} s ago`;
188 if (s < 3600) return `${Math.floor(s / 60)} min ago`;
189 if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
190 const d = Math.floor(s / 86400);
191 return `${d} ${d === 1 ? "day" : "days"} ago`;
192}
193
194function agentTime(ws: DisplayInput, now: Date): string {
195 const { agent, at } = ws.claude;
196 if (agent === "needs-you") return `asked a question ${ago(at, now)}`.trim();
197 if (agent === "working") return `last activity ${ago(ws.lastActivity ?? at, now)}`.trim();
198 if (agent === "done") return `idle since ${ago(at ?? ws.lastActivity, now)}`.trim();
199 return "no live turn, nothing asked";
200}
201
202function containers(ws: DisplayInput): Text {
203 const loops = ws.crashLoops ?? [];
204 if (loops.length === 1) return { text: `${loops[0]?.service} crash-looping`, tone: "err" };
205 if (loops.length > 1) return { text: `${loops.length} crash-looping`, tone: "err" };
206 const count = `${ws.running} of ${ws.total} up`;
207 if (ws.state === "up") return { text: count, tone: "ok" };
208 if (ws.state === "partial") return { text: count, tone: "warn" };
209 if (ws.state === "stopped") return { text: count, tone: "off" };
210 if (ws.state === "paused") return { text: "paused", tone: "off" };
211 if (ws.state === "down") return { text: "down", tone: "off" };
212 return { text: "Docker unreachable", tone: "off" };
213}
214
215function crash(ws: DisplayInput): Text | null {
216 const loops = ws.crashLoops ?? [];
217 if (!loops.length) return null;
218 const text = loops
219 .map((l) =>
220 [`✗ ${l.service} crash-looping`, l.restarts ? `${l.restarts} restarts` : null, l.lastLog]
221 .filter(Boolean)
222 .join(" · "),
223 )
224 .join("; ");
225 return { text, tone: "err" };
226}
227
228function usage(u: UsageInput | null | undefined): Text | null {
229 if (!u) return null;
230 return {
231 text: `${u.cpuPercent}% CPU · ${u.memMib} MiB`,
232 tone: u.cpuPercent >= 100 ? "warn" : "ok",
233 };
234}
235
236/** The pane's host line: `host load 3.2 · 2.8 · 2.1 / 10 cores · features 45% CPU`. */
237export function hostLine(h: HostInput | null): Text {
238 if (!h) return { text: "host load n/a", tone: "off" };
239 return {
240 text: `host load ${h.load.join(" · ")} / ${h.cores} cores · features ${h.featureCpuPercent}% CPU`,
241 tone: (h.load[0] ?? 0) > h.cores ? "warn" : "ok",
242 };
243}
244
245/** `https://github.com/o/repo/pull/12` → `repo#12`; the URL itself when it isn't one. */
246function prRef(url: string): string {
247 const m = url.match(/\/([^/]+)\/pull\/(\d+)/);
248 return m ? `${m[1]}#${m[2]}` : url;
249}
250
251function prState(p: PrInput): Text & { dashed: boolean } {
252 if (p.state === "OPEN")
253 return p.draft
254 ? { text: "Draft", tone: "off", dashed: true }
255 : { text: "Open", tone: "ok", dashed: false };
256 if (p.state === "MERGED")
257 return { text: p.release ? `Released ${p.release}` : "Merged", tone: "pr", dashed: false };
258 return { text: "Closed", tone: "off", dashed: false };
259}
260
261function ci(p: PrInput): Text | null {
262 if (p.ci === "pass") return { text: "CI ✓", tone: "ok" };
263 if (p.ci === "running") return { text: "CI running", tone: "warn" };
264 if (p.ci === "fail") return { text: `CI ✗ ${p.failingChecks} failing`, tone: "err" };
265 return null;
266}
267
268function workflow(ws: DisplayInput, lifecycle: Lifecycle | null): WorkspaceDisplay["workflow"] {
269 const step = lifecycle ? LIFECYCLE[lifecycle] : undefined;
270 if (step) return { kind: "lifecycle", text: step, compact: step, pips: [] };
271 const s = ws.stage;
272 if (s)
273 return {
274 kind: "icm",
275 text: `Stage ${s.index} of ${s.total} · ${s.name}`,
276 compact: `stage ${s.index}/${s.total} ${s.name}`,
277 pips: Array.from({ length: s.total }, (_, i) =>
278 i + 1 < s.index ? "done" : i + 1 === s.index ? "current" : "next",
279 ),
280 };
281 if (ws.phase)
282 return { kind: "jit", text: `JIT · ${ws.phase}`, compact: `JIT · ${ws.phase}`, pips: [] };
283 return { kind: "none", text: "", compact: "", pips: [] };
284}
285
286/** ui-spec § Actions → Primary action, in that order. */
287function primary(
288 ws: DisplayInput,
289 lifecycle: Lifecycle | null,
290 where: string,
291): WorkspaceDisplay["primary"] {
292 const cont = { action: "continue", label: `↗ Continue in ${where}`, destructive: false } as const;
293 if (ws.paused) return { action: "resume", label: "▶ Resume", destructive: false };
294 if (ws.claude.agent === "needs-you") return cont;
295 if (lifecycle === "shipped")
296 return { action: "close-out", label: "✓ Close out", destructive: true };
297 if (ws.state === "down" || ws.state === "stopped")
298 return { action: "up", label: "▶ Up", destructive: false };
299 return cont;
300}
301
302export function displayModel(ws: DisplayInput, opts: DisplayOptions = {}): WorkspaceDisplay {
303 const now = opts.now ?? new Date();
304 const lifecycle = opts.lifecycle ?? null;
305 const a = ws.paused ? PAUSED : AGENT[ws.claude.agent];
306 const needs = !ws.paused && ws.claude.agent === "needs-you";
307 const withPr = (opts.prs ?? []).filter((p): p is PrInput & { url: string } => p.url !== null);
308 return {
309 agent: {
310 ...a,
311 label: a.glyph ? `${a.glyph} ${a.word}` : a.word,
312 time: ws.paused ? `paused ${ago(ws.paused.at, now)}` : agentTime(ws, now),
313 },
314 needs,
315 question: needs ? ws.claude.question : null,
316 autonomous: ws.autonomous ? { full: "⚙ Autonomous", compact: "⚙ Auto" } : null,
317 containers: containers(ws),
318 services: ws.services.map(({ name, state }) => {
319 const s = ws.crashLoops?.some((l) => l.service === name) ? CRASH_LOOP : SERVICE[state];
320 return {
321 name,
322 glyph: s.glyph,
323 tone: s.tone,
324 codicon: s.codicon,
325 text: `${s.glyph} ${name}${s.word}`,
326 };
327 }),
328 usage: usage(opts.usage),
329 started: ws.started
330 ? { text: `started ${ago(ws.started.at, now)} by ${ws.started.by}`, tone: "off" }
331 : null,
332 crash: crash(ws),
333 prsEmpty:
334 opts.prs === null
335 ? { text: "PRs unavailable", tone: "warn" }
336 : withPr.length
337 ? null
338 : { text: "None yet", tone: "off" },
339 prs: withPr.map((p) => ({ ref: prRef(p.url), url: p.url, state: prState(p), ci: ci(p) })),
340 workflow: workflow(ws, lifecycle),
341 primary: primary(ws, lifecycle, opts.where ?? "Terminal"),
342 };
343}
344hooks/textarea.tsx 121 lines1import type { ClientModule } from "claude-code";
2
3/**
4 * The Create tab's text field, drawn by the plugin: the app's own Input carries
5 * an Enter hint chip a plugin cannot remove, and has no multi-line mode.
6 * Click to type; arrows, Home/End and Backspace/Delete edit; with `multiline`
7 * Enter adds a line, without it Enter submits. Posts to the hooks module:
8 * `{ focus: true }` on a click, `{ value }` on every edit, `{ submit: true }`
9 * on a one-line Enter. A new `value` prop (an issue fetch filled it) replaces
10 * the text; `focused` (the pane's last-clicked field) draws the cursor.
11 * Pasting does not reach it: the engine hands a plugin's region keys, not pastes.
12 */
13type Props = { value: string; placeholder: string; multiline: boolean; focused: boolean };
14type State = { text: string; cursor: number; seen: string };
15
16/** [line, column] of `cursor` in `text`. */
17function lineCol(text: string, cursor: number): [number, number] {
18 const before = text.slice(0, cursor).split("\n");
19 return [before.length - 1, (before.at(-1) ?? "").length];
20}
21
22/** The cursor index at `col` (clamped) on `line`. */
23function indexAt(text: string, line: number, col: number): number {
24 const lines = text.split("\n");
25 let i = 0;
26 for (let l = 0; l < line; l++) i += (lines[l] ?? "").length + 1;
27 return i + Math.min(col, (lines[line] ?? "").length);
28}
29
30/** One key applied to the text and cursor; the same object back when the key changes nothing. */
31export function edit(
32 s: { text: string; cursor: number },
33 key: string,
34 ctrl?: boolean,
35): { text: string; cursor: number } {
36 const { text, cursor } = s;
37 const [line, col] = lineCol(text, cursor);
38 const last = text.split("\n").length - 1;
39 switch (key) {
40 case "return":
41 return { text: text.slice(0, cursor) + "\n" + text.slice(cursor), cursor: cursor + 1 };
42 case "backspace":
43 return cursor === 0
44 ? s
45 : { text: text.slice(0, cursor - 1) + text.slice(cursor), cursor: cursor - 1 };
46 case "delete":
47 return cursor === text.length
48 ? s
49 : { text: text.slice(0, cursor) + text.slice(cursor + 1), cursor };
50 case "left":
51 return { text, cursor: Math.max(0, cursor - 1) };
52 case "right":
53 return { text, cursor: Math.min(text.length, cursor + 1) };
54 case "up":
55 return { text, cursor: line === 0 ? 0 : indexAt(text, line - 1, col) };
56 case "down":
57 return { text, cursor: line === last ? text.length : indexAt(text, line + 1, col) };
58 case "home":
59 return { text, cursor: cursor - col };
60 case "end":
61 return { text, cursor: indexAt(text, line, Infinity) };
62 }
63 // Space can arrive by name, which the next line would drop as a named key.
64 if (key === "space") key = " ";
65 // Any other named key (tab, pageup, f1, ...) or a ctrl chord types nothing.
66 if (ctrl || key === "" || (key.length > 1 && /^[a-z0-9]+$/.test(key))) return s;
67 return { text: text.slice(0, cursor) + key + text.slice(cursor), cursor: cursor + key.length };
68}
69
70const TextField: ClientModule<Props, State> = (props, surface) => {
71 const { Box, Text } = surface.elements;
72 let s = surface.state;
73 if (!s || props.value !== s.seen) {
74 // First draw, or the value changed from outside (a fetch): take it.
75 s = { text: props.value, cursor: props.value.length, seen: props.value };
76 surface.setState(s);
77 }
78 // Listeners are set on every draw (a later call replaces the last), so they see this draw's props.
79 surface.onPointer((ev) => {
80 if (ev.type === "down") surface.post({ focus: true });
81 });
82 surface.onKey((ev) => {
83 const cur = surface.state;
84 if (!cur) return;
85 if (ev.key === "return" && !props.multiline) {
86 surface.post({ submit: true });
87 return;
88 }
89 const next = edit(cur, ev.key, ev.ctrl);
90 if (next === cur) return;
91 surface.setState({ ...next, seen: next.text });
92 surface.post({ value: next.text });
93 });
94 const atEnd = s.text[s.cursor] === "\n" || s.cursor === s.text.length;
95 return (
96 <Box
97 borderStyle="round"
98 borderColor={props.focused ? "claude" : "inactive"}
99 paddingX={1}
100 flexDirection="column"
101 >
102 {s.text === "" && !props.focused ? (
103 <Text dimColor>
104 {props.placeholder ||
105 (props.multiline ? "Click to type. Enter adds a line." : "Click to type.")}
106 </Text>
107 ) : !props.focused ? (
108 <Text>{s.text}</Text>
109 ) : (
110 <Text>
111 {s.text.slice(0, s.cursor)}
112 <Text inverse>{atEnd ? " " : s.text[s.cursor]}</Text>
113 {s.text.slice(atEnd ? s.cursor : s.cursor + 1)}
114 </Text>
115 )}
116 </Box>
117 );
118};
119
120export default TextField;
121types/index.d.ts 205 lines1export type WorkspaceState = "up" | "partial" | "stopped" | "paused" | "down" | "unknown";
2
3export type WorkspaceRow = {
4 name: string;
5 path: string;
6 issueId: string | null;
7 /** The issue's page in the configured tracker; null when it can't be built from config. */
8 issueUrl: string | null;
9 /** Free-form tags from `.sandbox/workspace.yml`'s `labels` list; `focus` drives the pane's Focused only toggle. */
10 labels: string[];
11 /** `task` from .sandbox/workspace.yml. */
12 task: string | null;
13 /** `description` from .sandbox/workspace.yml. */
14 description: string | null;
15 /** `work_type` from .sandbox/workspace.yml. */
16 workType: string | null;
17 /** `feature` from .sandbox/workspace.yml: the key intercept rows and slots are filed under. */
18 feature: string | null;
19 /** The first ICM stage whose output/ is still empty; null when not an ICM workspace or every stage has output. */
20 stage: { index: number; total: number; name: string } | null;
21 /** werkmeister-core `lifecycleOf` — PR files only, no network; null when it could not be read. */
22 lifecycle: {
23 state: "working" | "ready" | "pr-open" | "merged" | "shipped" | "closed-out" | "abandoned";
24 prs: { dir: string; status: string; url: string | null }[];
25 } | null;
26 /** Title of the workspace's newest on-disk Claude session; the card shows none (SW-52), Continue needs one. */
27 sessionTitle: string | null;
28 /** That session's transcript `entrypoint`: claude-vscode, claude-desktop, sdk-cli, ...; null when unknown. */
29 sessionOrigin: string | null;
30 state: WorkspaceState;
31 running: number;
32 total: number;
33 services: ServiceHealth[];
34 /** The agent state core reports (SW-46): idle with no status, `at` and `question` null when unknown. */
35 claude: {
36 agent: "working" | "needs-you" | "done" | "idle";
37 at: string | null;
38 question: string | null;
39 };
40 /** JIT phase from the newest live note (SW-47), or null. */
41 phase: string | null;
42 /** `autonomous` in workspace.yml. */
43 autonomous: boolean;
44 /** mtime (ISO) of the newest Claude transcript, or null. */
45 lastActivity: string | null;
46 /** `sandbox-<feature>`: the key resources are joined on (SW-41). */
47 project: string | null;
48 /** The earliest running container's start and what started it; null when none runs. */
49 started: { at: string; by: "up" | "gate" | "restart" | "person" } | null;
50 /** Containers restarting under their restart policy, with their last log line. */
51 crashLoops: { service: string; restarts: number; lastLog: string | null }[];
52 /** Held by Pause (SW-70): when, and the containers Resume brings back; null when not paused. */
53 paused: { at: string; containers: string[] } | null;
54 /** Resume left a "continue" for this workspace's live session, for the pane to send. */
55 resumeNudge: boolean;
56 /** The desktop-app session whose cwd lies in this workspace. */
57 /** `beside`: this session started it, so the app will open it in split view next to this one. */
58 /** `pinned`: the session is pinned in the app's sidebar. */
59 session: {
60 id: string;
61 title: string;
62 link: string | null;
63 beside: boolean;
64 pinned: boolean;
65 } | null;
66};
67
68/** One repo worktree's PR for its branch (werkmeister-core getWorkspacePRs, via `gh`); url null when it has none. */
69export type RepoPr = {
70 repo: string;
71 url: string | null;
72 state: "OPEN" | "MERGED" | "CLOSED" | null;
73 title: string | null;
74 /** Absent from a core older than SW-47. */
75 ci?: "pass" | "running" | "fail" | null;
76 failingChecks?: number;
77};
78
79/** One container's health, as workspace-status reports it. */
80export type ServiceHealth = {
81 name: string;
82 state: "healthy" | "starting" | "unhealthy" | "running" | "down";
83};
84
85/** Shared infra (`sandbox connect`): the `infra` block of workspace-status. */
86export type InfraStatus = {
87 state: "up" | "partial" | "down" | "unknown";
88 running: number;
89 total: number;
90 services: ServiceHealth[];
91};
92
93/** werkmeister-core `getWorkspaceResources`: one stats sweep, CPU in % of one core. */
94export type WorkspaceResources = {
95 projects: Record<string, { cpuPercent: number; memMib: number }>;
96 host: { load: [number, number, number]; cores: number; featureCpuPercent: number };
97};
98
99/** One feature×service row of `sandbox status --intercepts --json` (werkmeister-core `InterceptRow`). */
100export type InterceptRow = {
101 feature: string;
102 service: string;
103 deployment: string | null;
104 interceptable: boolean;
105 blocker: string | null;
106 intercepted: boolean;
107 /** Null when the owning feature can't be read: never treat that intercept as yours. */
108 intercepted_by: string | null;
109};
110
111/** What `sandbox status --intercepts --json` prints (werkmeister-core `InterceptsSnapshot`). */
112export type InterceptsSnapshot = {
113 available: boolean;
114 /** On PATH, connected or not. Absent from a core older than SW-12: fall back to `available`. */
115 installed?: boolean;
116 connected_namespace: string | null;
117 intercepts: InterceptRow[];
118 /** Registry services with k8s coordinates; the rest get no intercept control. */
119 remote_services: string[];
120};
121
122/** One field of the Create tab's form, from the RJSF bundle getFormSchema builds. */
123export type FormField = {
124 key: string;
125 title: string;
126 /** The schema's description: one line under the title. */
127 help: string;
128 kind: "text" | "textarea" | "select" | "multi" | "check";
129 options: string[];
130 /** A multi-pick's options by group heading (`ui:options.groups`); {} draws them ungrouped. */
131 groups: Record<string, string[]>;
132 /** A refinable field's instruction for buildRefinePrompt ("" = its default); null when it has no Refine button. */
133 refine: string | null;
134 required: boolean;
135 pattern: string | null;
136 placeholder: string;
137};
138
139/** The RJSF bundle getFormSchema builds: what the browser form renders, and the Create tab too. */
140export type FormBundle = {
141 schema: {
142 properties?: Record<
143 string,
144 {
145 title?: string;
146 description?: string;
147 type?: string;
148 enum?: string[];
149 pattern?: string;
150 items?: { enum?: string[] };
151 }
152 >;
153 required?: string[];
154 };
155 uiSchema: Record<string, unknown>;
156 formData: Draft;
157};
158
159/** The Create tab's values by field key; a multi-pick is a list. */
160export type Draft = Record<string, string | string[] | boolean>;
161
162declare module "claude-code" {
163 interface PluginState {
164 werkmeister: {
165 rows: WorkspaceRow[];
166 error: string;
167 busy: string;
168 /** `close-out:<name>`, `abandon:<name>` or `intercept:<name>/<service>` between a button's two presses; '' when none is pending. */
169 confirmEnd: string;
170 /** The reason typed for a pending abandon. */
171 abandonReason: string;
172 note: string;
173 infra: InfraStatus | null;
174 /** The last stats sweep, refreshed slower than rows; null when it failed, unset before the first. */
175 resources: WorkspaceResources | null;
176 /** The last intercept read; null when it failed or timed out. */
177 intercepts: InterceptsSnapshot | null;
178 /** The configured issue tracker's sign-in check; null until checked or if the check failed. */
179 tracker: { ok: boolean; reason?: string; fix: string } | null;
180 /** Each workspace's repo PRs by workspace name; unset before the first poll (cards say `loading…`), refreshed slower than rows, as each is a `gh` call. */
181 prs: Record<string, RepoPr[]>;
182 /** How the last PR poll ended; `failed` reads `PRs unavailable · retry` on the cards. */
183 prsPoll: "ok" | "failed";
184 /** The card whose ⋯ menu is open, by workspace name; '' when none. */
185 menu: string;
186 /** The ? legend is shown. */
187 legend: boolean;
188 /** The pane's open tab. */
189 tab: "list" | "create";
190 /** List only workspaces labelled `focus`. */
191 focus: boolean;
192 /**
193 * The Create tab's form as loaded and the tracker's name; null until loaded. The raw
194 * bundle, not the fields: state outlives a plugin reload, and fields are drawn from it fresh.
195 */
196 formSchema: { bundle: FormBundle; trackerLabel: string } | null;
197 draft: Draft;
198 /** '' idle, or what the Create tab is waiting on. */
199 formBusy: "" | "fetch" | "create" | "refine";
200 /** The Create tab's last-clicked text field, by key: the one drawn with a cursor. */
201 focusedField: string;
202 };
203 }
204}
205