SLOPSHOPPER

Werkmeister

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

newpaneguardcommandtoasttool
v0.1.0Apache-2.0updated 2026-10-26jbbz-sandbox/werkmeister/plugins/werkmeister
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · werkmeister
│ ┃ Sandbox workspaces ✕ › fix the failing auth test and add an audit log call │ ┃ [ Workspaces ] [ + Create ] ☐ Focused only │ ┃ ⏺ Read(src/auth.ts) │ ┃ infra · unknown [ ⚡ Connect infra ] ⎿ Read 6 lines │ ┃ host load n/a ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Loading workspaces… ⏺ Bash(bun test) │ ┃ werkmeister-core gave no result (exit 0) ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /workspaces │ ⎿ werkmeister: Workspaces pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sandbox workspaces
[ Workspaces ] [ + Create ] ☐ Focused only ? infra · unknown [ ⚡ Connect infra ] host load n/a Loading workspaces… werkmeister-core gave no result (exit 0)
README

werkmeister

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.

Skills

Diagnostics & operations

SkillWhat it does
statusComprehensive health overview of the whole sandbox (containers, networks, proxy).
diagnoseFull-stack health checks across containers, networking, Caddy routing, and connectivity.
routing-testTrace a single URL through the routing chain to find where it breaks.
caddy-reloadValidate, reload, and smoke-test the Caddy proxy config safely.

Workspace lifecycle

SkillWhat it does
workspaceCreate 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-testStart a feature environment and open a headed Playwright browser for interactive testing.
starting-promptRun the starting_prompt defined in the workspace's .werkmeister/workspace.yml.

Mods

ModWhat it does
/workspacesOpens 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.

Code standards (sb-*) — NOT in this plugin

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

Bundled MCP server (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:

SurfaceHow it connectsUI
Claude Desktopclaude_desktop_config.json (Desktop doesn't install plugins)Inline MCP App (ui://): create form + workspace dashboard
Claude Code CLI / VS Codethis plugin's .mcp.jsonLocalhost 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.

Register on Claude Desktop

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

Dependencies

  • UI build (checkout only): run 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.
  • The w9r CLI (package werkmeister 0.14 or later) must be on PATH, and Docker running.
  • Self-contained: the workspace form and its "Refine with Claude" / issue-fetch logic live in apps/werkmeister-dashboard — no external skill is required. Issues come from the tracker picked in sandbox setup; refine uses the local claude CLI.

Install

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

Migrating from sandbox-tools

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

WasIs
plugin sandbox-toolswerkmeister
marketplace sandboxwerkmeister (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: in claude_desktop_config.json, rename the sandbox-workspace server to werkmeister, as in Register on Claude Desktop.
  • Anything of yours that names the old tools or skills (permission rules in your own settings, prompts that say /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.

Source 4 files
hooks/register.tsx 2150 lines
1import 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;
1200
hooks/display-model.ts 344 lines
1/**
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}
344
hooks/textarea.tsx 121 lines
1import 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;
121
types/index.d.ts 205 lines
1export 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