SLOPSHOPPER

workbench

Reports Claude Code session activity to the Workbench desktop app

newguardpromptmodelnetworktimer
v0.1.0AGPL-3.0updated 2026-10-09starkey-digital/workbench/plugins/workbench
A shopper browsing a rack in a slop shop
README

Workbench

A desktop terminal manager built with Tauri v2 and Svelte 5. Add local project folders, and each project gets a tabbed workspace with real shell terminals, Claude Code + Codex integration, and git worktree support.

Website · Download

The public landing page lives in site/ and deploys to GitHub Pages. See landing page development.

Installation

Download the latest release from the Releases page:

  • macOS (Universal) — Workbench_x.x.x_universal.dmg

Open the .dmg and drag Workbench to your Applications folder.

macOS is the primary target. Linux and Windows support is planned.

Claude Code plugin

Workbench tracks Claude session activity with its own Claude Code plugin (plugins/workbench), which it loads into every Claude process it starts, so there's nothing to install. To keep it loaded in sessions started outside Workbench too, install it from this repo:

/plugin marketplace add starkey-digital/workbench
/plugin install workbench@workbench

It only reports when Workbench started the session; anywhere else it does nothing.

Features

  • Project sidebar — add, edit, remove, and reorder local project folders
  • Tabbed workspaces — each project gets its own workspace with multiple terminal tabs
  • Terminal splitting — horizontal and vertical panes with resizable grid layout
  • Real shell terminals — native PTY sessions via portable-pty + xterm.js
  • Claude Code integration — start new Claude sessions, resume previous ones, and view session history per project
  • Codex integration — start and resume Codex sessions alongside Claude, with full session discovery
  • Git worktree support — create and manage worktrees with isolated workspaces per branch
  • Startup commands — auto-run commands when opening a project
  • VS Code integration — open any project or worktree in VS Code
  • Claude Code settings — view and edit Claude settings (user, project, local scopes) directly from Workbench
  • Persistent config — projects and workspaces saved to ~/.workbench/

Stack

Development

Prerequisites

  • Rust (for Tauri backend)
  • Bun (for frontend dependencies)
  • macOS (primary development target)

Getting Started

bun install
bun run dev

This runs tauri dev, which starts the Vite dev server on port 1420 and opens the Tauri window.

Commands

CommandDescription
bun installInstall dependencies
bun run devStart dev server + Tauri window
bun run buildBuild production .app and .dmg
bun run checkRun svelte-check type checking
bun run lintRun Prettier + ESLint checks
bun run formatAuto-format with Prettier
bun run testRun all tests (frontend + Rust)
bun run test:unitRun frontend unit tests only
bun run test:componentRun frontend component tests only

Rust tests can be run separately:

cargo test --manifest-path src-tauri/Cargo.toml

Project Structure

src/                    # Svelte frontend
  lib/
    components/         # Shared UI components (shadcn-svelte)
    features/           # Feature modules (projects, workspaces, terminal, claude, worktrees)
    stores/             # Svelte context-based stores
    utils/              # Shared utilities
  types/                # TypeScript type definitions
src-tauri/              # Rust backend
  src/
    commands.rs         # Tauri IPC command handlers
    config.rs           # Project/workspace persistence
    codex.rs            # Codex session discovery and config
    claude_sessions.rs  # Claude session discovery and hooks
    git.rs              # Git CLI wrappers (branches, worktrees)
    settings.rs         # Claude Code settings CRUD
    paths.rs            # Path helpers and atomic file writes

License

This project is licensed under the GNU Affero General Public License v3.0.

Source 7 files
hooks/register.ts 96 lines
1import type { EngineInterface, Register } from 'claude-code';
2import {
3	noteBackgroundTasks,
4	notePermissionMode,
5	noteTitle,
6	promptTitle,
7	register as registerChat
8} from './chat';
9
10const REFRESH_TOOLS = new Set(['Bash', 'Write', 'Edit', 'NotebookEdit']);
11
12// How long a hook waits on the desktop's hook bridge, and how long it then
13// doesn't wait at all once the bridge didn't answer in time.
14const BRIDGE_MS = 3_000;
15const BRIDGE_PAUSE_MS = 30_000;
16let bridgeSlowUntil = 0;
17
18// Workbench sets both variables on every shell and chat process it starts;
19// outside Workbench they are unset and the plugin does nothing. The socket is
20// `host:port#secret`, the secret proving the post comes from a process
21// Workbench started. Awaiting the POST (the bridge answers once it has
22// handled the event) keeps events in order; the classic hooks it runs in gate
23// the prompt, and a `$` call doesn't spend their budget, so a hung fetch is
24// raced against a clock wait (which does, at most `BRIDGE_MS`); after one
25// that ran out, events are still sent but not waited on for a while, rather
26// than costing every prompt the wait.
27async function forward<R>(
28	$: EngineInterface,
29	e: { permission_mode?: string; [field: string]: unknown },
30	next: () => Promise<R>
31): Promise<R> {
32	notePermissionMode(e.permission_mode);
33	const socket = await $.env.get('WORKBENCH_HOOK_SOCKET');
34	const paneId = await $.env.get('WORKBENCH_PANE_ID');
35	if (socket && paneId) {
36		const [address, secret = ''] = socket.split('#');
37		const post = $.http
38			.fetch(`http://${address}/hook`, {
39				method: 'POST',
40				headers: { 'content-type': 'application/json', 'x-workbench-hook-secret': secret },
41				body: JSON.stringify({ pane_id: paneId, hook: e })
42			})
43			.then(
44				() => true,
45				() => true
46			);
47		if (Date.now() >= bridgeSlowUntil) {
48			const waited = new AbortController();
49			const answered = await Promise.race([
50				post,
51				$.clock.sleep(BRIDGE_MS, { signal: waited.signal }).then(
52					() => false,
53					() => true
54				)
55			]);
56			waited.abort();
57			if (!answered) bridgeSlowUntil = Date.now() + BRIDGE_PAUSE_MS;
58		}
59	}
60	return next();
61}
62
63export const register: Register = (on, options) => {
64	registerChat(on, options);
65	// Both carry the session's title: a generated one reaches chat at the next prompt.
66	on('classic.SessionStart', ($, e, next) => {
67		noteTitle(e.session_title);
68		return forward($, e, () => next(e));
69	});
70	on('classic.UserPromptSubmit', ($, e, next) => {
71		const generated = promptTitle(e.session_title);
72		return forward($, e, async () => {
73			const result = await next(e);
74			return generated ? { ...result, sessionTitle: generated } : result;
75		});
76	});
77	on('classic.Stop', ($, e, next) => {
78		noteBackgroundTasks(e.background_tasks);
79		return forward($, e, () => next(e));
80	});
81	// Approvals and MCP forms reach both devices through the mod link (the
82	// desktop isn't sent Notification).
83	on('classic.Notification', ($, e, next) => {
84		notePermissionMode(e.permission_mode);
85		return next(e);
86	});
87	// The bridge only needs the tool and a Bash command; a Write's input and any
88	// tool's response can be whole files.
89	on('classic.PostToolUse', ($, e, next) => {
90		if (!REFRESH_TOOLS.has(e.tool_name)) return next(e);
91		const command = (e.tool_input as { command?: unknown } | undefined)?.command;
92		const { tool_response: _response, ...rest } = e;
93		return forward($, { ...rest, tool_input: { command } }, () => next(e));
94	});
95};
96
hooks/chat.ts 1250 lines
1import type { PermissionRequestDecision, Register, TurnStepInput } from 'claude-code';
2import * as server from './link';
3import { PendingAsks } from './asks';
4import { notifiedJob, startJob, stoppedJobs } from './jobs';
5import { Titles, TITLE_SYSTEM } from './titles';
6import {
7	askLine,
8	attachedFiles,
9	commandRowOutput,
10	KEEPALIVE_PROMPT,
11	midTurn,
12	modelOption,
13	promptText,
14	QUEUED_NUDGE,
15	QUEUED_PREFIX,
16	rateLimitLine,
17	slimResult,
18	slashCommand,
19	withAttachments,
20	type Line
21} from './lines';
22
23// Runs this interactive `claude` as a Workbench chat too: what `claude -p`
24// would print as stream-json is posted to the server (`/mod/out`), and what it
25// would read is long-polled (`/mod/in`), so the chat view and the terminal are
26// one process. Workbench sets both variables on terminal shells it starts;
27// outside Workbench the module does nothing.
28
29const { emit } = server;
30
31let polling = false;
32let model = '';
33// The model and effort last reported, so a change is sent once.
34let lastSettings = '';
35// The live permission mode, and the one the chat was last told.
36let liveMode: string | undefined;
37let reportedMode = '';
38let messageSeq = 0;
39let currentMessage = '';
40const startedBlocks = new Set<number>();
41let askSeq = 0;
42// Effort picked in chat; applied to each main-thread model request until the
43// TUI picks another, which then wins as the later pick does in the TUI.
44let effort: TurnStepInput['effort'];
45// The effort the engine resolved on its own when the chat's was applied first.
46let effortBase: { value: TurnStepInput['effort'] } | undefined;
47// Model picked in chat, for this session only: the resolved id each
48// main-thread request names, the pick as the chat shows it, the effort levels
49// that model takes (absent: unknown), and the model the engine names on its
50// own (`base`, from the first request after the pick). A request naming
51// another (a fallback) keeps it. A pick in the TUI (`/model`, `/config`) clears it.
52let modelPick: { id: string; choice: string; effortLevels?: string[]; base?: string } | undefined;
53// The slash commands the chat was last sent, to send a changed list once.
54let commandList = '';
55// The session title the chat was last sent.
56let title: string | undefined;
57const titles = new Titles();
58// The running turn answers the server's cache keep-alive: its reply names nothing.
59let keepAliveTurn = false;
60// The running main-thread turn, for an interrupt from chat.
61let runningTurn: string | undefined;
62// The live model's context window, from the latest measurement.
63let contextWindow: number | undefined;
64// The window the chat was last sent outside a `result`; a model switch
65// clears the chat's, so it's sent again.
66let sentWindow: number | undefined;
67// Why the last turn failed (StopFailure), and the id of the `result` that
68// reported it, whose notice takes the reason when StopFailure comes after.
69let failure: string | undefined;
70let failedResult: string | undefined;
71// The parts of tool results the transcript row doesn't carry (an edit's patch,
72// an Artifact's link), by call id, until the row and the result have met.
73const toolResults = new Map<string, Line>();
74const toolRows = new Map<string, Line>();
75const callsRunning = new Set<string>();
76const TOOL_RESULTS_KEPT = 50;
77// Main-thread Agent calls still running (task ids = tool call ids); progress
78// goes to the only one when a subagent wasn't linked.
79const runningAgents = new Set<string>();
80// Running subagents: agent id <-> task id, for progress and their output file.
81const agentTasks = new Map<string, string>();
82const taskAgents = new Map<string, string>();
83// Background agents: their Agent call returned at launch, so their own
84// `turn.complete` ends the task.
85const asyncAgents = new Set<string>();
86// Background agents the chat's Stop asked to end. No call stops one, so each ends
87// at its next model request or tool call (`endedByStop`), and then its "finished"
88// notification starts no turn. One that answered first keeps its notification.
89const stoppedAgents = new Set<string>();
90const endedByStop = new Set<string>();
91// Main-thread Skill calls still running: call id → the skill and the agents
92// there were before it. A forked skill's agent has no `agent.spawn`: the first
93// call of an agent new since then names it, and its Skill call is its task.
94const runningSkills = new Map<string, { skill: string; before: Set<string> }>();
95// Whether the latest prompt came from a chat: what it asks waits for a chat's
96// answer even before one has it open (a phone in the background).
97let chatTurn = false;
98// The chat's latest command: whether it started a turn or compaction or got its
99// `result` (`answered`), and whether `$.command.run` has resolved (`settled`).
100type ChatCommand = { answered: boolean; settled: boolean };
101let chatCommand: ChatCommand | undefined;
102const PANEL_WAIT_MS = 3000;
103const LIVE_AGENT = new Set(['pending', 'running', 'waiting']);
104// Chat prompts appended into the running turn that no request has read yet.
105let injected: string[] = [];
106// Prompts the plugin submitted itself, kept out of the chat (each echoes once).
107const echoed = new Set<string>();
108// What the latest command printed, through its `text` or its transcript row,
109// whichever came first: the other isn't shown again.
110const printed = new Set<string>();
111
112// A question is asked from `tool.call` and its answer is the call's result:
113// since 2.1.292 a plugin's `allow` doesn't dismiss the dialog of a tool that
114// requires the person. A plan is approved in the terminal (PermissionRequest).
115const ASKED_IN_CALL = 'AskUserQuestion';
116const pendingAsks = new PendingAsks();
117// Approvals the terminal's own dialog asks (no chat was open): tool call id →
118// request id, and whether the main thread asked it (a background agent's
119// outlives the turn).
120const askedInTerminal = new Map<string, { requestId: string; main: boolean }>();
121// MCP elicitations the terminal shows, by server and elicitation id, oldest first.
122const terminalElicitations = new Map<string, string[]>();
123// Model switches the person made (`/model`, `/config`, the chat's pick); an
124// automatic fallback or a resume keeps the pick the chat shows.
125const PICKED_MODEL = new Set(['command', 'picker', 'sdk']);
126
127// Without `model`: the TUI names the bare id, and resending it would drop
128// the `[1m]` the chat's model carries.
129function sendWindow(window: number | undefined) {
130	if (window === undefined || window === sentWindow) return;
131	sentWindow = window;
132	emit({ type: 'system', subtype: 'init', contextWindow: window });
133}
134
135function reply(requestId: unknown, error?: string, response: Line = {}) {
136	emit({
137		type: 'control_response',
138		response: error
139			? { subtype: 'error', request_id: requestId, error }
140			: { subtype: 'success', request_id: requestId, response }
141	});
142}
143
144function linkAgent(agent: string, task: string) {
145	agentTasks.set(agent, task);
146	taskAgents.set(task, agent);
147}
148
149// What a command printed, and the agents it started (a forked skill's has no
150// `agent.spawn`): each joins the tasks panel, and its own `turn.complete` ends it.
151function commandRan(
152	command: string,
153	args: string,
154	text: string | undefined,
155	agents: readonly {
156		id: string;
157		parentId?: string;
158		description?: string;
159		type?: string;
160		status: string;
161	}[],
162	before: Set<string>
163) {
164	for (const agent of agents) {
165		// A finished one is a turn's that ran while the command waited, not the command's.
166		if (before.has(agent.id) || agent.parentId || agentTasks.has(agent.id)) continue;
167		if (!LIVE_AGENT.has(agent.status)) continue;
168		linkAgent(agent.id, agent.id);
169		asyncAgents.add(agent.id);
170		emit(
171			{
172				type: 'system',
173				subtype: 'task_started',
174				task_id: agent.id,
175				description: agent.description || `/${command}`,
176				subagent_type: agent.type,
177				task_type: 'local_agent',
178				is_backgrounded: true,
179				uuid: `wbmod-task-${agent.id}`
180			},
181			{
182				type: 'system',
183				subtype: 'task_updated',
184				task_id: agent.id,
185				output_id: agent.id,
186				uuid: `wbmod-task-out-${agent.id}`
187			}
188		);
189	}
190	// Written to the JSONL only: the chat would see it on its next load.
191	if (command === 'rename') noteTitle(args);
192	if (text !== undefined) printOnce(text);
193}
194
195function printOnce(text: string) {
196	const key = text.trim();
197	if (printed.has(key)) return;
198	printed.add(key);
199	commandOutput(text);
200}
201
202function commandOutput(content: string, inTerminal = false) {
203	emit({
204		type: 'system',
205		subtype: 'local_command_output',
206		content,
207		...(inTerminal && { workbench_in_terminal: true }),
208		uuid: `wbmod-command-${++askSeq}`
209	});
210}
211
212// The `result` a chat's command that ran no turn ends with, once.
213function answerCommand(command: ChatCommand, failed = false) {
214	command.answered = true;
215	emit(
216		failed
217			? { type: 'result', subtype: 'error_during_execution', is_error: true }
218			: { type: 'result', subtype: 'success', is_error: false }
219	);
220}
221
222function startSkillTask(task: string, agent: string) {
223	linkAgent(agent, task);
224	emit(
225		{
226			type: 'system',
227			subtype: 'task_started',
228			task_id: task,
229			tool_use_id: task,
230			description: `/${runningSkills.get(task)?.skill ?? 'skill'}`,
231			task_type: 'local_agent',
232			uuid: `wbmod-task-${task}`
233		},
234		{
235			type: 'system',
236			subtype: 'task_updated',
237			task_id: task,
238			output_id: agent,
239			uuid: `wbmod-task-out-${task}`
240		}
241	);
242}
243
244function unlinkTask(task: string) {
245	const agent = taskAgents.get(task);
246	taskAgents.delete(task);
247	if (agent) {
248		agentTasks.delete(agent);
249		asyncAgents.delete(agent);
250	}
251}
252
253const elicitationKey = (e: { mcp_server_name: string; elicitation_id?: string }) =>
254	`${e.mcp_server_name}\0${e.elicitation_id ?? ''}`;
255
256/**
257 * The session's live permission mode, from a classic hook's input: the
258 * `/config` row holds only the settings default (`defaultMode`), not a mode
259 * the session was launched in or switched to.
260 */
261export function notePermissionMode(mode: string | undefined) {
262	if (!mode) return;
263	liveMode = mode;
264	if (!server.current() || mode === reportedMode) return;
265	reportedMode = mode;
266	emit({ type: 'permission-mode', permissionMode: mode });
267}
268
269/** Report background jobs that finished; called from the Stop hook with its job list. */
270export function noteBackgroundTasks(tasks: readonly { id: string; status: string }[] = []) {
271	if (server.current()) emit(...stoppedJobs(tasks));
272}
273
274/**
275 * The session's title (a `/rename`, or the generated one) as a hook input
276 * carries it; the chat folds it as the JSONL's rename.
277 */
278export function noteTitle(next: string | undefined) {
279	const trimmed = next?.trim();
280	if (!trimmed || trimmed === title || !server.current()) return;
281	title = trimmed;
282	emit({ type: 'custom-title', customTitle: trimmed });
283}
284
285/**
286 * The title for a prompt's `sessionTitle`: one generated since the last prompt
287 * (shown in chat at once, made the engine's own here), else the engine's.
288 */
289export function promptTitle(current: string | undefined): string | undefined {
290	const generated = titles.takePending();
291	noteTitle(generated ?? current);
292	return generated;
293}
294
295function commandsLine(commands: readonly { name: string; description: string }[]) {
296	const list = commands.map((c) => ({ name: c.name, description: c.description }));
297	const text = JSON.stringify(list);
298	if (text === commandList) return undefined;
299	commandList = text;
300	return { type: 'workbench_commands', commands: list };
301}
302
303export const register: Register = (on) => {
304	on('session.start', async ($, e, next) => {
305		const result = await next(e);
306		const url = await $.env.get('WORKBENCH_MOD_URL');
307		const token = await $.env.get('WORKBENCH_MOD_TOKEN');
308		if (!url || !token) return result;
309		const sessionId = await $.session.id();
310		model = await $.session.model();
311		server.open({ url, token, sessionId });
312		const fetch = (u: string, i: Parameters<typeof $.http.fetch>[1]) => $.http.fetch(u, i);
313		const after: server.After = (ms, fn) => $.clock.after(ms, fn);
314		// Bounded: `claude` waits on this hook before its first prompt. A failed
315		// hello (the server is down, or another terminal still holds the session)
316		// is said again from the timer below, so the session still becomes a chat.
317		await server.attach(fetch, false, after);
318
319		// A chat prompt. A plugin's submit waits for the turn to end, so
320		// mid-turn it joins the running turn the way a typed prompt would.
321		const chatPrompt = async (line: Line) => {
322			const message = line.message as { content?: unknown } | undefined;
323			const text = promptText(message?.content);
324			const files = attachedFiles(line);
325			if (!text) return;
326			// A plugin's submit refuses a leading `/`, so a known command runs as one
327			// (queued until idle); `/tmp is full` stays a prompt. A command that starts
328			// no turn (a forked skill, `/rename`) resolves to nothing for the model, so
329			// the chat gets its `result` here; a prompt-type one's turn ends with its own.
330			const slash = slashCommand(text);
331			if (slash && (await $.command.list()).some((c) => c.name === slash.command)) {
332				const before = new Set((await $.agent.list()).map((a) => a.id));
333				const command: ChatCommand = { answered: false, settled: false };
334				chatCommand = command;
335				printed.clear();
336				// Queued behind a turn typed at the terminal, it isn't that turn's to ask for.
337				if (!runningTurn) chatTurn = true;
338				const idle = (async () => {
339					while (runningTurn) await $.clock.sleep(250);
340				})();
341				const args = withAttachments(slash.args, files);
342				void $.command.run({ command: slash.command, args }).then(
343					async (result) => {
344						command.settled = true;
345						if (!server.current()) return;
346						await server.rekey(() => $.session.id());
347						commandRan(slash.command, slash.args, result.text, await $.agent.list(), before);
348						if (slash.command === 'rename' && slash.args.trim())
349							await titles.nameByPerson({
350								get: (k) => $.store.get(k),
351								set: (k, v) => $.store.set(k, v)
352							});
353						// Notes for the model may start a turn a moment later (`/goal`),
354						// whose `result` ends the command; a `/rename`'s start none.
355						for (let i = 0; result.context?.length && i < 4; i++) {
356							if (command.answered || runningTurn) break;
357							await $.clock.sleep(250);
358						}
359						if (!command.answered && !runningTurn) answerCommand(command);
360					},
361					(err: unknown) => {
362						command.settled = true;
363						if (!server.current()) return;
364						commandOutput(`/${slash.command} failed: ${String(err)}`);
365						if (!command.answered) answerCommand(command, true);
366					}
367				);
368				// A panel (`/usage`, `/config`) resolves only once it's closed in the TUI,
369				// and nothing tells it from a slow command: the chat is told where it is.
370				void (async () => {
371					await idle;
372					await $.clock.sleep(PANEL_WAIT_MS);
373					if (command.settled || command.answered || runningTurn || !server.current()) return;
374					commandOutput(
375						`/${slash.command} opened in the terminal: switch to Terminal to use or close it.`,
376						true
377					);
378					answerCommand(command);
379				})();
380				return;
381			}
382			const appended = runningTurn
383				? await $.session
384						.append({
385							message: { type: 'user', content: [{ type: 'text', text: midTurn(text, files) }] }
386						})
387						.catch((err: unknown) => ({ deny: String(err) }))
388				: undefined;
389			if (appended && !appended.deny && runningTurn) {
390				injected.push(text);
391				emit({
392					type: 'user',
393					uuid: `wbmod-queued-${++askSeq}`,
394					session_id: server.current()?.sessionId,
395					message: { role: 'user', content: text }
396				});
397			} else {
398				// Not awaited: it resolves when its turn starts, and polling must go
399				// on meanwhile (approval answers, interrupts).
400				void $.prompt.submit({ text: withAttachments(text, files), asUser: true });
401			}
402		};
403
404		const controlRequest = async (line: Line) => {
405			const req = (line.request ?? {}) as {
406				subtype?: string;
407				model?: string;
408				resolvedModel?: string;
409				effortLevels?: string[];
410				settings?: { effortLevel?: string };
411			};
412			const sub = req.subtype;
413			if (sub === 'interrupt') {
414				if (runningTurn) await $.turn.abort({ turnId: runningTurn }).catch(() => {});
415				for (const agent of await $.agent.list().catch(() => []))
416					if (asyncAgents.has(agent.id) && LIVE_AGENT.has(agent.status))
417						stoppedAgents.add(agent.id);
418				reply(line.request_id);
419			} else if (sub === 'initialize') {
420				// A fallback list: the server replaces it with the CLI's own.
421				const row = (await $.config.list()).find((r) => r.key === 'model');
422				const models = (row?.options ?? []).map(modelOption);
423				const commands = (await $.command.list()).map((c) => ({
424					name: c.name,
425					description: c.description
426				}));
427				commandList = JSON.stringify(commands);
428				const modelChoice =
429					modelPick?.choice ?? (typeof row?.value === 'string' ? row.value : undefined);
430				reply(line.request_id, undefined, { models, commands, modelChoice });
431			} else if (sub === 'set_model' && req.model) {
432				// `turn.step` takes an id, not an alias: the server resolves the pick.
433				if (!req.resolvedModel) {
434					reply(line.request_id, `Model: ${req.model} isn't in Claude's model list.`);
435					return;
436				}
437				modelPick = { id: req.resolvedModel, choice: req.model, effortLevels: req.effortLevels };
438				model = req.resolvedModel;
439				sentWindow = undefined;
440				reply(line.request_id);
441				emit({ type: 'system', subtype: 'init', model, modelChoice: req.model });
442			} else if (sub === 'apply_flag_settings' && req.settings?.effortLevel) {
443				effort = req.settings.effortLevel as TurnStepInput['effort'];
444				effortBase = undefined;
445				reply(line.request_id);
446			} else {
447				reply(line.request_id, `${sub ?? 'This'} isn't available in a terminal session yet.`);
448			}
449		};
450
451		$.clock.every(50, () => {
452			server.tick();
453			if (!server.isFlushing()) void server.flush(fetch);
454		});
455		$.clock.every(300, () => {
456			if (polling || !server.current()) return;
457			polling = true;
458			void (async () => {
459				await server.rekey(() => $.session.id());
460				if (server.hello.needed) {
461					if (Date.now() - server.hello.last < server.HELLO_EVERY_MS) return;
462					if (await server.attach(fetch, true, after)) await reattached();
463					return;
464				}
465				for (const line of await server.poll(fetch)) {
466					try {
467						if (line.type === 'user') await chatPrompt(line);
468						else if (line.type === 'control_request') await controlRequest(line);
469					} catch {
470						// Not run again: a line that fails would fail every time.
471					} finally {
472						server.handled(line);
473					}
474				}
475			})()
476				.catch(() => {})
477				.finally(() => (polling = false));
478		});
479
480		// The server lost the session and loaded it again from its file: what it
481		// was told outside the history is told again.
482		const reattached = async () => {
483			lastSettings = '';
484			reportedMode = '';
485			sentWindow = undefined;
486			notePermissionMode(liveMode);
487			if (title) emit({ type: 'custom-title', customTitle: title });
488			// A restarted server has no plan usage reading until a window moves.
489			const usage = await $.session.usage().catch(() => undefined);
490			const limits = usage && rateLimitLine(usage.rateLimits);
491			if (limits) emit(limits);
492			sendWindow(contextWindow ?? usage?.context.window);
493		};
494
495		// No hook has named the live mode yet: the one Workbench started `claude`
496		// in beats the settings default.
497		liveMode ??= (await $.env.get('WORKBENCH_PERMISSION_MODE')) || undefined;
498		const rows = await $.config.list().catch(() => []);
499		const configMode = rows.find((r) => r.key === 'permissionMode')?.value;
500		const permissionMode = liveMode ?? (typeof configMode === 'string' ? configMode : undefined);
501		reportedMode = permissionMode ?? '';
502		emit({ type: 'system', subtype: 'init', session_id: sessionId, model, permissionMode });
503		const usage = await $.session.usage().catch(() => undefined);
504		const limits = usage && rateLimitLine(usage.rateLimits);
505		if (limits) emit(limits);
506		// A measurement taken meanwhile is newer.
507		contextWindow ??= usage?.context.window;
508		sentWindow = undefined;
509		sendWindow(contextWindow);
510		return result;
511	});
512
513	on('session.end', async ($, e, next) => {
514		const link = server.current();
515		if (link && (e.reason === 'clear' || e.reason === 'resume')) {
516			server.expectRekey(e.reason);
517			titles.reset();
518			for (const task of [...taskAgents.keys()]) unlinkTask(task);
519			stoppedAgents.clear();
520			endedByStop.clear();
521			return next(e);
522		}
523		if (link) {
524			// One request, inside the short bound an exit gets.
525			const bye = server.init('POST', server.takeOutbox());
526			server.open(null);
527			const left = Math.max(0, next.budget.remainingMs - 100);
528			await Promise.race([
529				(async () => {
530					await server.flush((u, i) => $.http.fetch(u, i));
531					await $.http.fetch(`${link.url}/mod/bye`, bye).catch(() => {});
532				})(),
533				$.clock.sleep(left)
534			]);
535		}
536		return next(e);
537	});
538
539	on('session.measure', ($, e, next) => {
540		if (server.current()) {
541			contextWindow = e.context.window;
542			sendWindow(contextWindow);
543			const limit = e.changed.includes('rateLimits') ? rateLimitLine(e.rateLimits) : undefined;
544			if (limit) emit(limit);
545		}
546		return next(e);
547	});
548
549	// A subagent's run raises none: this is always the main thread's turn.
550	// Commands come and go (a plugin or skill loaded), so the chat's list is
551	// re-read here.
552	on('turn.start', async ($, e, next) => {
553		runningTurn = e.turnId;
554		// A prompt-type command's turn ends with its own `result`.
555		if (chatCommand) chatCommand.answered = true;
556		failure = undefined;
557		failedResult = undefined;
558		const result = await next(e);
559		const commands = server.current() ? commandsLine(await $.command.list()) : undefined;
560		if (commands) emit(commands);
561		return result;
562	});
563
564	on('turn.step', async function* ($, e, next) {
565		if (e.agentId && stoppedAgents.has(e.agentId)) {
566			endedByStop.add(e.agentId);
567			return {
568				turnId: e.turnId,
569				index: e.index,
570				answer: '',
571				toolUses: [],
572				stopReason: 'end_turn',
573				usage: null
574			};
575		}
576		if (!server.current() || e.agentId) return yield* next(e);
577		await server.rekey(() => $.session.id());
578		const sessionId = server.current()?.sessionId ?? '';
579		// This request carries every row appended so far.
580		injected = [];
581		// The TUI picked another effort: the chat's is dropped (and the server forgets it).
582		let effortCleared = false;
583		if (effort) {
584			if (!effortBase) effortBase = { value: e.effort };
585			else if (e.effort !== effortBase.value) {
586				effort = effortBase = undefined;
587				effortCleared = true;
588			}
589		}
590		if (modelPick && modelPick.base === undefined) modelPick.base = e.model;
591		const pick = modelPick && e.model === modelPick.base ? modelPick : undefined;
592		// What this request really runs with: the engine resolved both. A picked
593		// model that doesn't take the effort goes without.
594		let sent = effort ?? e.effort;
595		if (pick?.effortLevels && !(typeof sent === 'string' && pick.effortLevels.includes(sent)))
596			sent = undefined;
597		const step = { ...e, ...(pick ? { model: pick.id } : {}) };
598		if (sent !== e.effort) step.effort = sent;
599		const stream = next(step);
600		currentMessage = `wbmod-${sessionId.slice(0, 8)}-${++messageSeq}`;
601		startedBlocks.clear();
602		model = step.model || model;
603		const settings = `${model} ${sent ?? ''}`;
604		if (settings !== lastSettings) {
605			lastSettings = settings;
606			emit({
607				type: 'system',
608				subtype: 'init',
609				model,
610				// `null`: this model runs without effort.
611				effort: typeof sent === 'string' ? sent : null,
612				...(effortCleared ? { effortCleared } : {})
613			});
614		}
615		emit({
616			type: 'stream_event',
617			event: { type: 'message_start', message: { id: currentMessage, model } }
618		});
619		for await (const chunk of stream) {
620			if (chunk.kind === 'text' || chunk.kind === 'thinking') {
621				if (!startedBlocks.has(chunk.index)) {
622					startedBlocks.add(chunk.index);
623					emit({
624						type: 'stream_event',
625						event: {
626							type: 'content_block_start',
627							index: chunk.index,
628							content_block: { type: chunk.kind, text: '' }
629						}
630					});
631				}
632				emit({
633					type: 'stream_event',
634					event: {
635						type: 'content_block_delta',
636						index: chunk.index,
637						delta:
638							chunk.kind === 'text'
639								? { type: 'text_delta', text: chunk.text }
640								: { type: 'thinking_delta', thinking: chunk.text }
641					}
642				});
643			} else if (chunk.kind === 'tool') {
644				emit({
645					type: 'stream_event',
646					event: {
647						type: 'content_block_start',
648						index: chunk.index,
649						content_block: { type: 'tool_use', id: chunk.id, name: chunk.name, input: {} }
650					}
651				});
652			} else if (chunk.kind === 'stop') {
653				if (chunk.usage?.model) model = chunk.usage.model;
654				// The response's rows were appended block by block before its usage
655				// was known: the context size and cache timer read it from here.
656				if (chunk.usage)
657					emit({
658						type: 'assistant',
659						uuid: `${currentMessage}-usage`,
660						session_id: sessionId,
661						message: { id: currentMessage, model, content: [], usage: chunk.usage }
662					});
663			}
664			yield chunk;
665		}
666		return stream.result;
667	});
668
669	on('session.append', async ($, e, next) => {
670		const m = e.message;
671		if (!server.current() || e.agentId) return next(e);
672		await server.rekey(() => $.session.id());
673		const sessionId = server.current()?.sessionId;
674		if (!sessionId) return next(e);
675		if (m.type === 'attachment' && m.name === 'goal_status') {
676			// The hook's view has none of its fields: the server reads the row
677			// from the session file, written once `next` returns.
678			const result = await next(e);
679			emit({ type: 'workbench_goal_status', uuid: e.uuid });
680			return result;
681		}
682		if (e.door === 'command') {
683			// A command's own rows: its name, the chat's echo of a command it sent,
684			// and what it printed, which `$.command.run` and the `command.run` hook
685			// may not return as `text` (`/goal`, `/rename`).
686			const text = promptText(m.content);
687			const out = commandRowOutput(text);
688			if (text.includes('<command-name>'))
689				emit({
690					type: 'user',
691					uuid: e.uuid,
692					session_id: sessionId,
693					message: { role: 'user', content: text }
694				});
695			else if (out) printOnce(out);
696			return next(e);
697		}
698		if (m.type === 'attachment' && m.name === 'queued_command') {
699			// A prompt typed in the terminal while a turn ran, folded into that
700			// turn. Only a prompt is framed so (history shows only those too).
701			const text = promptText(m.content);
702			if (text.startsWith(QUEUED_PREFIX)) {
703				emit({
704					type: 'user',
705					uuid: e.uuid,
706					session_id: sessionId,
707					message: { role: 'user', content: text }
708				});
709				await titles.use(sessionId, (k) => $.store.get(k));
710				titles.notePrompt(text.slice(QUEUED_PREFIX.length));
711			}
712		} else if (m.isMeta) {
713			return next(e);
714		} else if (m.type === 'assistant' && e.door === 'response') {
715			if (!keepAliveTurn) titles.noteReply(promptText(m.content));
716			emit({
717				type: 'assistant',
718				uuid: e.uuid,
719				session_id: sessionId,
720				message: { ...m, id: currentMessage || `wbmod-${e.uuid}`, model }
721			});
722		} else if (m.type === 'user' && e.door === 'prompt') {
723			const text = promptText(m.content);
724			keepAliveTurn = text === KEEPALIVE_PROMPT;
725			if (!keepAliveTurn && text !== QUEUED_NUDGE) {
726				await titles.use(sessionId, (k) => $.store.get(k));
727				titles.notePrompt(text);
728			}
729			if (echoed.delete(text)) return next(e);
730			emit({ type: 'user', uuid: e.uuid, session_id: sessionId, message: m });
731		} else if (m.type === 'user' && e.door === 'tool-result') {
732			const row: Line = { type: 'user', uuid: e.uuid, session_id: sessionId, message: m };
733			const id = (m.content as { tool_use_id?: string }[] | undefined)?.find(
734				(b) => b.tool_use_id
735			)?.tool_use_id;
736			const result = id ? toolResults.get(id) : undefined;
737			if (id) toolResults.delete(id);
738			// Still running: its structured result is sent with the row once it returns.
739			if (id && result === undefined && callsRunning.has(id)) toolRows.set(id, row);
740			emit(result === undefined ? row : { ...row, tool_use_result: result });
741		}
742		return next(e);
743	});
744
745	on('prompt.suggest', async ($, e, next) => {
746		const shown = await next(e);
747		if (server.current()) emit({ type: 'prompt_suggestion', suggestion: e.text });
748		return shown;
749	});
750
751	on('turn.complete', ($, e, next) => {
752		const linked = server.current() !== null;
753		const task = e.agentId && asyncAgents.has(e.agentId) ? agentTasks.get(e.agentId) : undefined;
754		if (e.agentId) stoppedAgents.delete(e.agentId);
755		if (linked && task && e.agentId) {
756			unlinkTask(task);
757			emit({
758				type: 'system',
759				subtype: 'task_notification',
760				task_id: task,
761				status:
762					e.reason === 'aborted' || endedByStop.has(e.agentId)
763						? 'stopped'
764						: e.reason === 'answer'
765							? 'completed'
766							: 'failed',
767				uuid: `wbmod-done-${task}`
768			});
769		}
770		// A call denied in the terminal's dialog never reaches `tool.call`.
771		if (!e.agentId)
772			for (const [id, asked] of askedInTerminal) if (asked.main) askedInTerminal.delete(id);
773		if (linked && !e.agentId) {
774			runningTurn = undefined;
775			if (e.reason === 'refusal')
776				emit({
777					type: 'system',
778					subtype: 'model_refusal_no_fallback',
779					original_model: model,
780					api_refusal_explanation: e.refusal.explanation,
781					uuid: `wbmod-refusal-${++askSeq}`
782				});
783			const modelUsage = contextWindow ? { [model]: { contextWindow } } : undefined;
784			if (e.reason === 'error') {
785				failedResult = `wbmod-result-${++askSeq}`;
786				emit({
787					type: 'result',
788					subtype: 'error_during_execution',
789					is_error: true,
790					result: failure ?? 'The turn failed.',
791					uuid: failedResult,
792					modelUsage
793				});
794			} else {
795				emit({ type: 'result', subtype: 'success', is_error: false, modelUsage });
796			}
797			keepAliveTurn = false;
798			// Not awaited: the title follows the turn, nothing waits on it.
799			if (e.reason === 'answer')
800				void titles
801					.retitle(
802						title,
803						(prompt) =>
804							$.model
805								.complete({
806									model: 'haiku',
807									system: TITLE_SYSTEM,
808									prompt,
809									maxTokens: 40,
810									effort: 'low',
811									timeoutMs: 20_000
812								})
813								.then((r) => (r.isAnswered ? r.text : undefined)),
814						{
815							get: (k) => $.store.get(k),
816							set: (k, v) => $.store.set(k, v),
817							keys: () => $.store.keys(),
818							delete: (k) => $.store.delete(k)
819						}
820					)
821					.then((generated) => generated && noteTitle(generated))
822					.catch(() => {});
823			const unread = injected.length > 0;
824			injected = [];
825			// Entries live one turn at most, so a stale one can't hide a later prompt.
826			echoed.clear();
827			// Stopped: the prompt stays in the chat, as an interrupted CLI leaves it.
828			if (e.reason === 'aborted' || !unread) return next(e);
829			// The unread prompts are in the conversation already; only ask for an answer.
830			echoed.add(QUEUED_NUDGE);
831			void $.prompt.submit({ text: QUEUED_NUDGE, asUser: true });
832		}
833		return next(e);
834	});
835
836	// An API error ended the turn: the chat says why instead of a bare failure.
837	on('classic.StopFailure', ($, e, next) => {
838		if (server.current()) {
839			failure = e.error_details || `The turn failed: ${e.error.replace(/_/g, ' ')}.`;
840			// The turn's `result` went out already: only its notice takes the reason.
841			if (failedResult)
842				emit({
843					type: 'system',
844					subtype: 'local_command_output',
845					content: failure,
846					uuid: failedResult
847				});
848		}
849		return next(e);
850	});
851
852	on('classic.PostModelSwitch', ($, e, next) => {
853		// The later pick wins, as it does in the TUI.
854		if (PICKED_MODEL.has(e.source)) modelPick = undefined;
855		if (server.current()) {
856			model = e.to_model;
857			sentWindow = undefined;
858			emit({
859				type: 'system',
860				subtype: 'init',
861				model: e.to_model,
862				...(PICKED_MODEL.has(e.source) ? { modelChoice: e.requested_model ?? 'default' } : {})
863			});
864		}
865		return next(e);
866	});
867
868	// Fires when auto mode's classifier denies a call.
869	on('classic.PermissionDenied', ($, e, next) => {
870		notePermissionMode(e.permission_mode);
871		pendingAsks.drop(e.tool_use_id);
872		if (server.current() && !e.agent_id)
873			emit({
874				type: 'system',
875				subtype: 'permission_denied',
876				tool_name: e.tool_name,
877				tool_use_id: e.tool_use_id,
878				decision_reason_type: 'classifier',
879				decision_reason: e.reason,
880				uuid: `wbmod-denied-${e.tool_use_id}`
881			});
882		return next(e);
883	});
884
885	// An MCP server asks for input. The terminal's dialog answers it; the chat
886	// shows a read-only card, and the session waits on it until it's answered.
887	on('classic.Elicitation', ($, e, next) => {
888		if (server.current()) {
889			const id = `wbmod-elicit-${++askSeq}`;
890			const key = elicitationKey(e);
891			terminalElicitations.set(key, [...(terminalElicitations.get(key) ?? []), id]);
892			emit({
893				type: 'workbench_terminal_elicitation',
894				id,
895				mcp_server_name: e.mcp_server_name,
896				message: e.message,
897				mode: e.mode,
898				url: e.url,
899				requested_schema: e.requested_schema
900			});
901		}
902		return next(e);
903	});
904
905	on('classic.ElicitationResult', ($, e, next) => {
906		const key = elicitationKey(e);
907		const id = terminalElicitations.get(key)?.shift();
908		if (terminalElicitations.get(key)?.length === 0) terminalElicitations.delete(key);
909		if (server.current() && id)
910			emit({ type: 'workbench_terminal_elicitation_result', id, action: e.action });
911		return next(e);
912	});
913
914	// A command typed at the terminal. A chat's runs through `$.command.run`, which
915	// skips this plugin's own hook, so `chatPrompt` does the same itself.
916	on('command.run', async ($, e, next) => {
917		if (e.origin.kind === 'composer') chatTurn = false;
918		if (!server.current()) return next(e);
919		await server.rekey(() => $.session.id());
920		printed.clear();
921		const before = new Set((await $.agent.list()).map((a) => a.id));
922		const result = await next(e);
923		if (!server.current()) return result;
924		await server.rekey(() => $.session.id());
925		commandRan(e.command, e.args, result.text, await $.agent.list(), before);
926		// A bare `/rename` asks the engine for a name: not a person's.
927		if (e.command === 'rename' && e.args.trim()) {
928			const id = server.current()?.sessionId;
929			if (id) await titles.use(id, (k) => $.store.get(k));
930			await titles.nameByPerson({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) });
931		}
932		return result;
933	});
934
935	// `/compact` (the chat's, or the server's upkeep) runs no turn either: the chat
936	// needs the boundary and a `result`. Auto compaction runs inside a turn, which
937	// ends with its own.
938	on('session.compact', async ($, e, next) => {
939		const ours = !e.agentId && e.trigger !== 'precompute';
940		// The chat's `/compact` running: not a panel waiting to be closed, however long it takes.
941		if (ours && e.trigger === 'manual' && chatCommand) chatCommand.answered = true;
942		const result = await next(e);
943		if (!server.current() || !ours) return result;
944		if (!result.messages) {
945			emit({
946				type: 'system',
947				subtype: 'local_command_output',
948				content: result.skip,
949				uuid: `wbmod-compact-${++askSeq}`
950			});
951		} else {
952			emit({
953				type: 'system',
954				subtype: 'compact_boundary',
955				compact_metadata: { trigger: e.trigger, pre_tokens: result.tokensBefore },
956				uuid: `wbmod-compact-${++askSeq}`
957			});
958		}
959		if (!runningTurn) emit({ type: 'result', subtype: 'success', is_error: false });
960		return result;
961	});
962
963	// A chat's prompt reaches the session through this plugin's own submit;
964	// one typed at the terminal hands what the turn asks back to the terminal.
965	on('prompt.submit', async ($, e, next) => {
966		const tasks =
967			e.origin.kind === 'task-notification'
968				? [...e.text.matchAll(/<task-id>([^<]+)<\/task-id>/g)].map((m) => m[1] ?? '')
969				: [];
970		if (tasks.length && tasks.every((t) => endedByStop.has(t))) {
971			for (const t of tasks) endedByStop.delete(t);
972			return { drop: 'Stopped in Workbench chat' };
973		}
974		if (e.origin.kind === 'plugin') chatTurn = e.origin.name === $.plugin.name;
975		else if (e.origin.kind === 'composer') chatTurn = false;
976		return next(e);
977	});
978
979	// A background job's notification names how it ended (a killed one too);
980	// the Stop hook's job list can only tell running from gone.
981	on('session.receive', ($, e, next) => {
982		const line =
983			server.current() && !e.agentId && e.origin.kind === 'task-notification'
984				? notifiedJob(e.text)
985				: undefined;
986		if (line) emit(line);
987		return next(e);
988	});
989
990	// Structured results (an Artifact's link) and subagents for the tasks panel.
991	on('tool.call', async ($, e, next) => {
992		if (e.agentId && stoppedAgents.has(e.agentId)) return { deny: 'Stopped in Workbench chat' };
993		if (!server.current()) return next(e);
994		const id = e.tool_use_id;
995		// Approved in the terminal's dialog: it no longer waits, however long the call runs.
996		// The call goes ahead: whatever settled it, it no longer waits on a dialog.
997		if (id) pendingAsks.drop(id);
998		const asked = id ? askedInTerminal.get(id) : undefined;
999		if (id && asked) {
1000			askedInTerminal.delete(id);
1001			emit({ type: 'control_cancel_request', request_id: asked.requestId });
1002		}
1003		if (e.tool === ASKED_IN_CALL && !e.agentId && id) {
1004			const { tool: _tool, tool_use_id: _id, agentId: _agent, ...input } = e;
1005			const requestId = `wbmod-ask-${++askSeq}`;
1006			const answer = await server.askInChat(
1007				(u, i) => $.http.fetch(u, i),
1008				(ms, fn) => $.clock.after(ms, fn),
1009				requestId,
1010				askLine(requestId, e.tool, input, id),
1011				next.signal,
1012				chatTurn
1013			);
1014			if (answer) {
1015				if (answer.behavior !== 'allow')
1016					return { deny: answer.message || 'Declined in Workbench chat' };
1017				const answered = { ...e, ...(answer.updatedInput ?? {}) } as typeof e;
1018				return {
1019					result: {
1020						questions: answered.questions,
1021						answers: answered.answers ?? {},
1022						...(answered.annotations ? { annotations: answered.annotations } : {})
1023					}
1024				};
1025			}
1026		}
1027		const input = e as unknown as { description?: string; subagent_type?: string };
1028		const isAgent = !e.agentId && e.tool === 'Agent';
1029		const isSkill = !e.agentId && e.tool === 'Skill';
1030		if (isSkill && id) {
1031			const before = new Set((await $.agent.list()).map((a) => a.id));
1032			runningSkills.set(id, {
1033				skill: (e as unknown as { skill?: string }).skill ?? 'skill',
1034				before
1035			});
1036		}
1037		const skillTask = runningSkills.size === 1 ? [...runningSkills.keys()][0] : undefined;
1038		if (e.agentId && skillTask && !taskAgents.has(skillTask) && !agentTasks.has(e.agentId)) {
1039			const agent = (await $.agent.list()).find((a) => a.id === e.agentId);
1040			if (agent && !agent.parentId && !runningSkills.get(skillTask)!.before.has(agent.id))
1041				startSkillTask(skillTask, agent.id);
1042		}
1043		if (isAgent && id) {
1044			runningAgents.add(id);
1045			emit({
1046				type: 'system',
1047				subtype: 'task_started',
1048				task_id: id,
1049				tool_use_id: id,
1050				description: input.description ?? 'Agent',
1051				subagent_type: input.subagent_type,
1052				task_type: 'local_agent',
1053				uuid: `wbmod-task-${id}`
1054			});
1055		} else if (e.agentId && (agentTasks.has(e.agentId) || runningAgents.size === 1)) {
1056			emit({
1057				type: 'system',
1058				subtype: 'task_progress',
1059				task_id: agentTasks.get(e.agentId) ?? [...runningAgents][0],
1060				last_tool_name: e.tool,
1061				uuid: `wbmod-progress-${++askSeq}`
1062			});
1063		}
1064		const main = id && !e.agentId ? id : undefined;
1065		if (main) callsRunning.add(main);
1066		let result: Awaited<ReturnType<typeof next>>;
1067		try {
1068			result = await next(e);
1069		} catch (err) {
1070			// No result comes: what the call holds goes, or the one-running-call
1071			// guesses above would pin later progress on it.
1072			if (id && (isAgent || (isSkill && taskAgents.has(id)))) {
1073				unlinkTask(id);
1074				emit({
1075					type: 'system',
1076					subtype: 'task_notification',
1077					task_id: id,
1078					status: 'failed',
1079					uuid: `wbmod-done-${id}`
1080				});
1081			}
1082			if (id) {
1083				runningAgents.delete(id);
1084				runningSkills.delete(id);
1085			}
1086			if (main) {
1087				callsRunning.delete(main);
1088				toolRows.delete(main);
1089			}
1090			throw err;
1091		}
1092		const launched = result.result as
1093			| { status?: string; agentId?: string; success?: boolean; background?: boolean }
1094			| undefined;
1095		if (isAgent && id) {
1096			runningAgents.delete(id);
1097		}
1098		if (isSkill && id) {
1099			const forked = launched?.status === 'forked' ? launched.agentId : undefined;
1100			const guessed = taskAgents.get(id);
1101			if (guessed && guessed !== forked) unlinkTask(id);
1102			if (forked && guessed !== forked) startSkillTask(id, forked);
1103			if (forked && launched?.background) asyncAgents.add(forked);
1104			else if (taskAgents.has(id) || guessed) {
1105				unlinkTask(id);
1106				emit({
1107					type: 'system',
1108					subtype: 'task_notification',
1109					task_id: id,
1110					status:
1111						launched?.success === false || result.isError || result.deny ? 'failed' : 'completed',
1112					uuid: `wbmod-done-${id}`
1113				});
1114			}
1115			runningSkills.delete(id);
1116		}
1117		if (isAgent && id && launched?.status === 'async_launched' && launched.agentId) {
1118			asyncAgents.add(launched.agentId);
1119			linkAgent(launched.agentId, id);
1120		} else if (isAgent && id) {
1121			unlinkTask(id);
1122			emit({
1123				type: 'system',
1124				subtype: 'task_notification',
1125				task_id: id,
1126				status: result.deny || result.isError ? 'failed' : 'completed',
1127				uuid: `wbmod-done-${id}`
1128			});
1129		}
1130		const bg = (result.result as { backgroundTaskId?: string } | undefined)?.backgroundTaskId;
1131		if (e.tool === 'Bash' && bg && startJob(bg)) {
1132			emit({
1133				type: 'system',
1134				subtype: 'task_started',
1135				task_id: bg,
1136				tool_use_id: id,
1137				description: (e as { command?: string }).command ?? 'Background command',
1138				task_type: 'local_bash',
1139				is_backgrounded: true,
1140				uuid: `wbmod-bg-${bg}`
1141			});
1142		}
1143		if (main) {
1144			callsRunning.delete(main);
1145			const slim = slimResult(result.result);
1146			const row = toolRows.get(main);
1147			toolRows.delete(main);
1148			if (row && slim) emit({ ...row, tool_use_result: slim });
1149			else if (slim) {
1150				// The row comes after the call returns.
1151				toolResults.set(main, slim);
1152				if (toolResults.size > TOOL_RESULTS_KEPT)
1153					toolResults.delete(toolResults.keys().next().value!);
1154			}
1155		}
1156		return result;
1157	});
1158
1159	on('agent.spawn', async ($, e, next) => {
1160		const result = await next(e);
1161		// The spawn names its Agent call; only the main thread's calls are tasks.
1162		const task = runningAgents.has(e.tool_use_id) ? e.tool_use_id : undefined;
1163		if (server.current() && task && result.agentId) {
1164			linkAgent(result.agentId, task);
1165			// The CLI writes a subagent's log as `<agent id>.output`.
1166			emit({
1167				type: 'system',
1168				subtype: 'task_updated',
1169				task_id: task,
1170				output_id: result.agentId,
1171				uuid: `wbmod-task-out-${task}`
1172			});
1173		}
1174		return result;
1175	});
1176
1177	on('tool.check', async ($, e, next) => {
1178		const verdict = await next(e);
1179		if (
1180			verdict.decision === 'ask' &&
1181			server.current() &&
1182			e.tool_use_id &&
1183			e.tool !== ASKED_IN_CALL
1184		) {
1185			pendingAsks.note(e.tool, e.input, { id: e.tool_use_id, reason: verdict.reason });
1186		}
1187		return verdict;
1188	});
1189
1190	// Fires only when the mode's decider would show its dialog: rules, the mode
1191	// and auto mode's classifier have all had their say. Asked in chat while one
1192	// is open; the server answers `fallback` when none has shown it, and the
1193	// terminal's dialog asks instead. A held request in flight doesn't spend the
1194	// hook's time budget, however long the person takes.
1195	on('classic.PermissionRequest', async ($, e, next) => {
1196		notePermissionMode(e.permission_mode);
1197		if (!server.current() || e.tool_name === ASKED_IN_CALL) return next(e);
1198		// Approving a plan is what leaves plan mode, and since 2.1.292 only the
1199		// dialog does that (a hook's allow doesn't dismiss it): it stays the
1200		// terminal's, and the chat says where to answer it.
hooks/link.ts 353 lines
1import type { EngineInterface, Timer } from 'claude-code';
2import { askAnswer, type Answer, type Line } from './lines';
3
4// The session's link to the Workbench server: lines go out through a queue
5// (`/mod/out`), approvals are held open (`/mod/ask`), and the server's lines
6// are long-polled (`/mod/in`). The hooks module hands in the engine calls it
7// needs, spelled there.
8//
9// Both directions are numbered so a lost reply costs nothing: every line this
10// side sends carries its number (`seq`, the first line's, the rest following)
11// and stays queued until a reply confirms it, so each request resends all the
12// unconfirmed ones from the oldest and the server folds a number once; the
13// server keeps each `/mod/in` line until a later poll acknowledges it (`ack`).
14// The numbering is this worker's (`epoch`): a restarted worker (a hot reload)
15// starts again at 1 and the server counts afresh from its hello.
16
17type Fetch = EngineInterface['http']['fetch'];
18/** `$.clock.after`, spelled in the hook: a bound that holds without the session's timer. */
19export type After = (ms: number, fn: () => void) => Timer;
20
21// The host's fetch takes no signal and has been seen never to settle, so every
22// request is given up after a bound: a hung post must not hold the queue (and
23// the whole chat) forever. The server holds `/mod/ask` and `/mod/in` up to 20 s.
24const POST_MS = 10_000;
25const ASK_MS = 30_000;
26const POLL_MS = 30_000;
27export const HELLO_MS = 5_000;
28// Failed `/mod/ask` calls in a row before the terminal's dialog takes over.
29const ASK_TRIES = 3;
30// A failed post is retried after this, not on every tick with a growing body.
31const RETRY_MS = 1_000;
32// Lines kept while the server can't take them (it's down); the oldest go.
33const OUTBOX_KEPT = 5_000;
34/** Between hellos while the server doesn't know the session. */
35export const HELLO_EVERY_MS = 5_000;
36
37interface Link {
38	url: string;
39	token: string;
40	sessionId: string;
41}
42
43let link: Link | null = null;
44const epoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
45let outbox: { seq: number; line: Line }[] = [];
46let lastSeq = 0;
47// The newest `/mod/in` line handled, acknowledged on the next poll.
48let inSeq = 0;
49let flushing: Promise<void> | null = null;
50let retryAt = 0;
51// `/clear` or `/resume` ended the session and the process carries on under another id.
52let rekeyPending: 'clear' | 'resume' | undefined;
53
54/** The server doesn't know this session (it restarted, a stop detached it, the first hello failed). */
55export const hello = { needed: false, last: 0 };
56
57/** The time deadlines are read against; tests move it. */
58export const clock = { now: () => Date.now() };
59
60export function current(): Link | null {
61	return link;
62}
63
64export function open(next: Link | null) {
65	link = next;
66}
67
68export function init(method: string, body?: unknown) {
69	return {
70		method,
71		headers: { 'content-type': 'application/json', 'x-workbench-mod-token': link?.token ?? '' },
72		body: body === undefined ? undefined : JSON.stringify(body)
73	};
74}
75
76export function sessionQuery() {
77	return `sessionId=${encodeURIComponent(link?.sessionId ?? '')}`;
78}
79
80// Queued, then posted in order (see the top).
81export function emit(...lines: Line[]) {
82	if (!link) return;
83	for (const line of lines) outbox.push({ seq: ++lastSeq, line });
84	if (outbox.length > OUTBOX_KEPT) outbox.splice(0, outbox.length - OUTBOX_KEPT);
85	tick();
86}
87
88type Batch = { seq?: number; lines: Line[] };
89
90/** Every unconfirmed line, as a request carries them. */
91function unconfirmed(): Batch & { epoch: string } {
92	return { epoch, seq: outbox[0]?.seq, lines: outbox.map((e) => e.line) };
93}
94
95/** A reply showed the server has `batch`. */
96function confirm(batch: Batch) {
97	if (batch.seq === undefined) return;
98	const last = batch.seq + batch.lines.length - 1;
99	outbox = outbox.filter((e) => e.seq > last);
100}
101
102/** Everything queued, for the last request a session sends (`/mod/bye`). */
103export function takeOutbox() {
104	const batch = unconfirmed();
105	outbox = [];
106	return { sessionId: link?.sessionId, ...batch };
107}
108
109export function isFlushing() {
110	return flushing !== null;
111}
112
113// Deadlines read the wall clock: `Date.now()` advances in the hooks
114// environment, unlike a `$.clock.sleep`, which would spend the waiting hook's
115// budget (an approval may be held for minutes). They're checked on the
116// session's timer, on every line queued and whenever a bounded request
117// settles; one that must hold even if the timer stops also gets an `After`.
118const deadlines = new Set<{ at: number; done: () => void }>();
119
120export function tick() {
121	const now = clock.now();
122	for (const d of deadlines) if (d.at <= now) d.done();
123}
124
125/** `work`, or `undefined` once `ms` have passed. */
126export function within<T>(ms: number, work: Promise<T>, after?: After): Promise<T | undefined> {
127	let done = () => {};
128	const timeout = new Promise<undefined>((resolve) => (done = () => resolve(undefined)));
129	const deadline = { at: clock.now() + ms, done };
130	deadlines.add(deadline);
131	let timer: Timer | undefined;
132	try {
133		timer = after?.(ms, done);
134	} catch {
135		// The tick still bounds it.
136	}
137	return Promise.race([work, timeout]).finally(() => {
138		deadlines.delete(deadline);
139		timer?.cancel();
140		tick();
141	});
142}
143
144/** Post what is queued, after any post in flight. */
145export async function flush(fetch: Fetch) {
146	while (flushing) await flushing;
147	if (outbox.length === 0 || !link || hello.needed || clock.now() < retryAt) return;
148	const batch = unconfirmed();
149	const post = fetch(`${link.url}/mod/out`, init('POST', { sessionId: link.sessionId, ...batch }));
150	flushing = within(POST_MS, post)
151		.then((res) => {
152			if (res?.status === 404) hello.needed = true;
153			if (res?.ok) confirm(batch);
154			else retryAt = clock.now() + RETRY_MS;
155		})
156		.catch(() => {
157			retryAt = clock.now() + RETRY_MS;
158		})
159		.finally(() => (flushing = null));
160	await flushing;
161}
162
163/**
164 * Attach the session (`/mod/hello`). When the server loaded it afresh (it had
165 * lost it), the history it read holds what was queued meanwhile: those lines
166 * go. A session it still had (an earlier hello's reply was lost) keeps them.
167 */
168export async function attach(fetch: Fetch, again: boolean, after?: After): Promise<boolean> {
169	if (!link) return false;
170	const queued = lastSeq;
171	hello.last = clock.now();
172	const res = await within(
173		HELLO_MS,
174		fetch(`${link.url}/mod/hello`, init('POST', { sessionId: link.sessionId, epoch })).catch(
175			() => null
176		),
177		after
178	);
179	if (!res?.ok) {
180		hello.needed = true;
181		return false;
182	}
183	hello.needed = false;
184	retryAt = 0;
185	const reply = helloReply(res.text);
186	// Where the server's lines stand: a new link starts at 0; one it still had
187	// counts from the newest this worker (or the one before it) acknowledged.
188	inSeq = reply.loaded ? reply.inSeq : Math.max(inSeq, reply.inSeq);
189	if (again && reply.loaded) outbox = outbox.filter((e) => e.seq > queued);
190	return true;
191}
192
193/** An older server answers nothing: every hello loaded the session. */
194function helloReply(text: string): { inSeq: number; loaded: boolean } {
195	try {
196		const reply = JSON.parse(text) as { inSeq?: unknown; loaded?: unknown };
197		return {
198			inSeq: typeof reply.inSeq === 'number' ? reply.inSeq : 0,
199			loaded: reply.loaded !== false
200		};
201	} catch {
202		return { inSeq: 0, loaded: true };
203	}
204}
205
206/**
207 * The server's lines for this session (a chat's prompts and requests) not
208 * yet handled; each is acknowledged with `handled` once it has run.
209 */
210export async function poll(fetch: Fetch): Promise<Line[]> {
211	if (!link || hello.needed) return [];
212	const res = await within(
213		POLL_MS,
214		fetch(`${link.url}/mod/in?${sessionQuery()}&ack=${inSeq}`, init('GET')).catch(() => null)
215	);
216	if (res?.status === 404) hello.needed = true;
217	if (!res?.ok) return [];
218	const lines = JSON.parse(res.text || '[]') as Line[];
219	// An older server numbers nothing.
220	return lines.filter((line) => typeof line.wbSeq !== 'number' || line.wbSeq > inSeq);
221}
222
223/** `line` from `poll` has run (or failed): the next poll acknowledges it. */
224export function handled(line: Line) {
225	if (typeof line.wbSeq === 'number') inSeq = Math.max(inSeq, line.wbSeq);
226}
227
228/**
229 * Withdraw a chat card at once, past a queue a hung post may hold.
230 * `inTerminal`: the terminal's dialog asks it now, so the chat says so.
231 */
232function withdraw(fetch: Fetch, requestId: string, inTerminal = false) {
233	if (!link) return;
234	const lines = [
235		{
236			type: 'control_cancel_request',
237			request_id: requestId,
238			...(inTerminal && { workbench_in_terminal: true })
239		}
240	];
241	void within(
242		POST_MS,
243		fetch(`${link.url}/mod/out`, init('POST', { sessionId: link.sessionId, lines }))
244	).catch(() => {});
245}
246
247/** The session ended (`clear`, `resume`) and the process goes on under another id. */
248export function expectRekey(reason: 'clear' | 'resume') {
249	rekeyPending = reason;
250}
251
252/** After `/clear` or `/resume` the server hears of the new id before any of its lines. */
253export async function rekey(sessionId: () => Promise<string>) {
254	if (!rekeyPending || !link) return;
255	const id = await sessionId();
256	if (!rekeyPending || !link || id === link.sessionId) return;
257	const resumed = rekeyPending === 'resume';
258	rekeyPending = undefined;
259	// The server finds the session by token until the reset re-keys it.
260	emit({
261		type: 'conversation_reset',
262		new_conversation_id: id,
263		session_id: link.sessionId,
264		...(resumed ? { resumed } : {})
265	});
266	link = { ...link, sessionId: id };
267}
268
269/** `work`, or `undefined` as soon as `signal` aborts. */
270function untilAborted<T>(signal: AbortSignal, work: Promise<T>): Promise<T | undefined> {
271	return Promise.race([
272		work,
273		new Promise<undefined>((resolve) =>
274			signal.addEventListener('abort', () => resolve(undefined), { once: true })
275		)
276	]);
277}
278
279/**
280 * Ask in chat and hold until a client answers: `null` falls back to the
281 * terminal's dialog. `hold` keeps it a chat's even before one has it open.
282 * An abort (Esc) withdraws the card at once.
283 */
284export async function askInChat(
285	fetch: Fetch,
286	after: After | undefined,
287	requestId: string,
288	line: Line,
289	signal: AbortSignal,
290	hold: boolean
291): Promise<Answer | null> {
292	// The tool's card reaches the chat before its approval does: the queued
293	// lines go with the request, as waiting on the queue's post, a `$` call of
294	// another context, would spend this hook's budget.
295	// Sent until a reply shows the server has it (it ignores a repeat).
296	let first: Line | undefined = line;
297	let answer: Answer | null | undefined;
298	let failed = 0;
299	while (answer === undefined && link && !signal.aborted) {
300		const batch = unconfirmed();
301		const res = await untilAborted(
302			signal,
303			within(
304				ASK_MS,
305				fetch(
306					`${link.url}/mod/ask`,
307					init('POST', { sessionId: link.sessionId, requestId, line: first, ...batch, hold })
308				).catch(() => null),
309				after
310			)
311		);
312		if (signal.aborted) break;
313		// A lost reply is asked again: the server keeps an answer until its call has a result.
314		if (!res?.ok) {
315			if (res?.status === 404) hello.needed = true;
316			if (++failed < ASK_TRIES) continue;
317			// The chat's card would stay answerable with nobody waiting on it.
318			withdraw(fetch, requestId, true);
319			return null;
320		}
321		confirm(batch);
322		failed = 0;
323		first = undefined;
324		answer = askAnswer(res.text);
325	}
326	if (signal.aborted) withdraw(fetch, requestId);
327	return answer ?? null;
328}
329
330/**
331 * A request only the terminal's dialog can answer: the server shows the
332 * session waiting on it there (and says so to phone and desktop) without a
333 * card. The queued lines go first, as with `askInChat`.
334 */
335export async function askInTerminal(
336	fetch: Fetch,
337	after: After | undefined,
338	requestId: string,
339	line: Line
340) {
341	if (!link) return;
342	const batch = unconfirmed();
343	const res = await within(
344		POST_MS,
345		fetch(
346			`${link.url}/mod/ask`,
347			init('POST', { sessionId: link.sessionId, requestId, line, ...batch, terminal: true })
348		).catch(() => null),
349		after
350	);
351	if (res?.ok) confirm(batch);
352}
353
hooks/asks.ts 57 lines
1// Calls core put to the mode's decider, by tool and input: the decider's
2// dialog (`PermissionRequest`) names no call. A rule, the mode or auto mode's
3// classifier settles most without one, so only the newest are kept.
4// Identical calls made in parallel queue under one key, oldest first; a call
5// that goes ahead (`tool.call`) or is denied leaves the queue (`drop`).
6
7export type PendingAsk = { id: string; reason?: string };
8
9const KEPT = 50;
10
11export class PendingAsks {
12	private byKey = new Map<string, PendingAsk[]>();
13	private size = 0;
14
15	private static key(tool: string, input: unknown) {
16		return `${tool}\0${JSON.stringify(input)}`;
17	}
18
19	note(tool: string, input: unknown, ask: PendingAsk) {
20		const key = PendingAsks.key(tool, input);
21		const before = this.byKey.get(key) ?? [];
22		const list = [...before.filter((a) => a.id !== ask.id), ask];
23		// The newest key goes last, so the oldest is dropped first.
24		this.byKey.delete(key);
25		this.byKey.set(key, list);
26		this.size += list.length - before.length;
27		while (this.size > KEPT) {
28			const [oldest, asks] = this.byKey.entries().next().value!;
29			asks.shift();
30			this.size--;
31			if (asks.length === 0) this.byKey.delete(oldest);
32		}
33	}
34
35	/** Call `id` was settled without a dialog. */
36	drop(id: string) {
37		for (const [key, asks] of this.byKey) {
38			const kept = asks.filter((a) => a.id !== id);
39			if (kept.length === asks.length) continue;
40			this.size -= asks.length - kept.length;
41			if (kept.length) this.byKey.set(key, kept);
42			else this.byKey.delete(key);
43			return;
44		}
45	}
46
47	/** The oldest pending call a `PermissionRequest` for this tool and input is about. */
48	take(tool: string, input: unknown): PendingAsk | undefined {
49		const key = PendingAsks.key(tool, input);
50		const asks = this.byKey.get(key);
51		const ask = asks?.shift();
52		if (ask) this.size--;
53		if (asks?.length === 0) this.byKey.delete(key);
54		return ask;
55	}
56}
57
hooks/jobs.ts 47 lines
1import { taskNotification, taskStatus, type Line } from './lines';
2
3// Background jobs (`run_in_background` Bash) in the tasks panel, by id, with
4// the status last reported. A job's own notification says how it ended; the
5// Stop hook's job list is the fallback when no notification names it.
6
7const jobs = new Map<string, string>();
8
9/** A job just went to the background; false if it was reported already. */
10export function startJob(id: string): boolean {
11	if (jobs.has(id)) return false;
12	jobs.set(id, 'running');
13	return true;
14}
15
16function report(id: string, status: string, summary?: string): Line | undefined {
17	if (jobs.get(id) === status) return undefined;
18	jobs.set(id, status);
19	if (status === 'running') return undefined;
20	return {
21		type: 'system',
22		subtype: 'task_notification',
23		task_id: id,
24		status,
25		...(summary ? { summary } : {}),
26		uuid: `wbmod-bg-done-${id}`
27	};
28}
29
30/** A `task-notification` delivery: the real outcome (completed, failed, killed). */
31export function notifiedJob(text: string): Line | undefined {
32	const note = taskNotification(text);
33	return note && jobs.has(note.id) ? report(note.id, note.status, note.summary) : undefined;
34}
35
36/**
37 * The Stop hook's job list. A running job missing from it has ended; it's
38 * called completed until its notification (if one comes) says otherwise.
39 */
40export function stoppedJobs(tasks: readonly { id: string; status: string }[]): Line[] {
41	const live = new Map(tasks.map((t) => [t.id, t.status]));
42	return [...jobs]
43		.filter(([, status]) => status === 'running')
44		.map(([id]) => report(id, taskStatus(live.get(id) ?? 'completed')))
45		.filter((line): line is Line => line !== undefined);
46}
47
hooks/titles.ts 187 lines
1// Keeps the session's title fitting the conversation: after an answered turn
2// a small model reads the opening prompt, the latest ones and the reply, and
3// names it. A title someone gave (`/rename`) is theirs and stays. What it
4// knows of a session is kept in the plugin's store under the session's id, so
5// a restart (a mode switch, a rewind, the app) carries on where it was.
6
7/** One completion, spelled in the hook (`$.model.complete`). */
8export type Complete = (prompt: string) => Promise<string | undefined>;
9
10/** `$.store`, spelled in the hook. */
11export interface Store {
12	get: (key: string) => Promise<unknown>;
13	set: (key: string, value: unknown) => Promise<void>;
14	keys?: () => Promise<string[]>;
15	delete?: (key: string) => Promise<void>;
16}
17
18interface Saved {
19	first?: string;
20	recent: string[];
21	prompts: number;
22	named: boolean;
23}
24
25const RECENT_PROMPTS = 3;
26const PROMPT_CHARS = 600;
27const REPLY_CHARS = 800;
28const TITLE_CHARS = 60;
29/** Every prompt names the session at first, then every `RETITLE_EVERY`th. */
30const EARLY_PROMPTS = 3;
31const RETITLE_EVERY = 5;
32const SESSIONS_KEPT = 200;
33const KEY = 'title:';
34
35export const TITLE_SYSTEM =
36	'You name coding-assistant conversations for a sidebar. Reply with the title only: ' +
37	'3 to 6 words, sentence case, no quotes or trailing punctuation, naming the task ' +
38	"(e.g. 'Fix stale chat after tool calls'). If the current title still fits, reply with it unchanged.";
39
40const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text);
41
42const fresh = (): Saved => ({ recent: [], prompts: 0, named: false });
43
44export class Titles {
45	private session: string | undefined;
46	private loading: Promise<void> = Promise.resolve();
47	private saved: Saved = fresh();
48	private reply = '';
49	/** A prompt came since the last title was asked for. */
50	private due = false;
51	private generating = false;
52	/** Bumped when the conversation changes, so a title asked before it is dropped. */
53	private epoch = 0;
54	/** Generated, and not yet handed to the engine (`sessionTitle`). */
55	private pending: string | undefined;
56
57	/** Work on `sessionId`'s state, read from the store the first time. */
58	use(sessionId: string, get: Store['get']): Promise<void> {
59		if (sessionId !== this.session) {
60			this.forget();
61			this.session = sessionId;
62			this.loading = get(KEY + sessionId)
63				.then((value) => {
64					if (this.session === sessionId && isSaved(value)) this.saved = value;
65				})
66				.catch(() => {});
67		}
68		return this.loading;
69	}
70
71	/** `/clear` or `/resume`: another conversation, read afresh on its first use. */
72	reset() {
73		this.forget();
74		this.session = undefined;
75	}
76
77	private forget() {
78		this.epoch++;
79		this.saved = fresh();
80		this.reply = '';
81		this.due = false;
82		this.pending = undefined;
83	}
84
85	/** A prompt the person (or chat) sent; Workbench's own prompts are skipped by the caller. */
86	notePrompt(text: string) {
87		const trimmed = text.trim();
88		if (!trimmed || trimmed.startsWith('<command-')) return;
89		const s = this.saved;
90		s.first ??= trimmed;
91		s.recent = [...s.recent, trimmed].slice(-RECENT_PROMPTS);
92		s.prompts++;
93		this.due = s.prompts <= EARLY_PROMPTS || s.prompts % RETITLE_EVERY === 0;
94	}
95
96	noteReply(text: string) {
97		if (text.trim()) this.reply = text.trim();
98	}
99
100	/** Someone named the session (`/rename <name>`): no more automatic titles for it. */
101	async nameByPerson(store: Store) {
102		this.saved.named = true;
103		this.pending = undefined;
104		await this.save(store);
105	}
106
107	/** The title to hand the engine with the next prompt, once. */
108	takePending(): string | undefined {
109		const title = this.pending;
110		this.pending = undefined;
111		return title;
112	}
113
114	/** After an answered turn: a new title, or `undefined` to keep `current`. */
115	async retitle(
116		current: string | undefined,
117		complete: Complete,
118		store: Store
119	): Promise<string | undefined> {
120		const { first, recent, named } = this.saved;
121		if (named || this.generating || !this.due || !first) return undefined;
122		this.generating = true;
123		this.due = false;
124		const epoch = this.epoch;
125		try {
126			const later = recent.filter((p) => p !== first);
127			const prompt = [
128				`Current title: ${current ?? '(none)'}`,
129				`Opening request:\n${clip(first, PROMPT_CHARS)}`,
130				later.length > 0 &&
131					`Latest requests:\n${later.map((p) => `- ${clip(p, PROMPT_CHARS)}`).join('\n')}`,
132				this.reply && `Latest reply:\n${clip(this.reply, REPLY_CHARS)}`
133			]
134				.filter(Boolean)
135				.join('\n\n');
136			const title = clean(await complete(prompt).catch(() => undefined));
137			// The conversation changed, or a rename landed, while the model answered.
138			if (epoch !== this.epoch) return undefined;
139			await this.save(store, true);
140			if (!title || this.saved.named || title === current) return undefined;
141			this.pending = title;
142			return title;
143		} finally {
144			this.generating = false;
145		}
146	}
147
148	private async save(store: Store, prune = false) {
149		const session = this.session;
150		if (!session) return;
151		try {
152			await store.set(KEY + session, this.saved);
153			if (!prune || !store.keys || !store.delete) return;
154			const keys = (await store.keys()).filter((k) => k.startsWith(KEY));
155			for (const key of keys.slice(0, Math.max(0, keys.length - SESSIONS_KEPT)))
156				await store.delete(key);
157		} catch {
158			// A store that can't be written costs only the memory across restarts.
159		}
160	}
161}
162
163function isSaved(value: unknown): value is Saved {
164	const v = value as Saved | null;
165	return (
166		typeof v === 'object' &&
167		v !== null &&
168		Array.isArray(v.recent) &&
169		typeof v.prompts === 'number' &&
170		typeof v.named === 'boolean'
171	);
172}
173
174/** The first line, without wrapping quotes or a closing full stop, cut at a word. */
175export function clean(text: string | undefined): string | undefined {
176	const line = text?.trim().split('\n')[0] ?? '';
177	const title = line
178		.replace(/^["'`“”‘’]+|["`“”‘’]+$/g, '')
179		.replace(/\.+$/, '')
180		.trim();
181	if (!title) return undefined;
182	if (title.length <= TITLE_CHARS) return title;
183	const cut = title.slice(0, TITLE_CHARS);
184	const space = cut.lastIndexOf(' ');
185	return space > 0 ? cut.slice(0, space) : cut;
186}
187
hooks/lines.ts 197 lines
1import type { PermissionRequestDecision, SessionRateLimit } from 'claude-code';
2
3// The stream-json lines the chat folds and the text the plugin frames: no
4// engine calls here, those stay in the hooks module.
5
6export type Line = Record<string, unknown>;
7export type Answer = {
8	behavior?: string;
9	message?: string;
10	updatedInput?: Record<string, unknown>;
11	updatedPermissions?: Extract<
12		PermissionRequestDecision,
13		{ behavior: 'allow' }
14	>['updatedPermissions'];
15};
16
17/**
18 * Sent when chat prompts put into a turn were never read by it: the model
19 * finds them in the conversation already. Core's `QUEUED_NUDGE` hides it.
20 */
21export const QUEUED_NUDGE = 'Please answer the message I sent while you were working.';
22/** The server's prompt-cache ping; core's `KEEPALIVE_PROMPT`. */
23export const KEEPALIVE_PROMPT = 'Workbench cache keep-alive. Reply with only "ok".';
24/** How the CLI frames a prompt typed while a turn runs; core's `QUEUED_PROMPT_PREFIX`. */
25export const QUEUED_PREFIX = 'The user sent a new message while you were working:\n';
26const ATTACHED_FILES = '\n\nAttached files (read each with the Read tool):\n';
27
28const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
29
30/** A `/config` model value as the chat's picker shows it. */
31export function modelOption(value: string) {
32	const wide = value.endsWith('[1m]');
33	const base = value.replace('[1m]', '');
34	const name = base === 'opusplan' ? 'Opus Plan' : base.charAt(0).toUpperCase() + base.slice(1);
35	return {
36		value,
37		displayName: wide ? `${name} (1M context)` : name,
38		...(base === 'haiku' ? {} : { supportedEffortLevels: EFFORT_LEVELS })
39	};
40}
41
42// The server turns attachments into `@path` mentions, so a prompt is text.
43export function promptText(content: unknown): string {
44	if (typeof content === 'string') return content;
45	const blocks = Array.isArray(content) ? (content as { type?: string; text?: string }[]) : [];
46	return blocks
47		.filter((b) => b.type === 'text')
48		.map((b) => b.text ?? '')
49		.join('\n');
50}
51
52/** What a command's transcript row printed (`Goal set: …`), if it's an output row. */
53export function commandRowOutput(text: string): string | undefined {
54	const out = /^<local-command-(stdout|stderr)>([\s\S]*)<\/local-command-\1>$/.exec(text.trim());
55	return out?.[2]?.trim() || undefined;
56}
57
58// What the transcript reads of a tool's structured result: an edit's patch,
59// an Artifact's link. The rest (whole files, outputs) stays off the wire.
60const RESULT_KEYS = [
61	'structuredPatch',
62	'url',
63	'title',
64	'version',
65	'created_from_type',
66	'updated',
67	'opened'
68];
69
70export function slimResult(result: unknown): Line | undefined {
71	if (!result || typeof result !== 'object') return undefined;
72	const kept = Object.entries(result).filter(([k]) => RESULT_KEYS.includes(k));
73	return kept.length ? Object.fromEntries(kept) : undefined;
74}
75
76export function askLine(
77	requestId: string,
78	tool: string,
79	input: unknown,
80	toolUseId?: string,
81	reason?: string,
82	suggestions?: unknown
83): Line {
84	return {
85		type: 'control_request',
86		request_id: requestId,
87		request: {
88			subtype: 'can_use_tool',
89			tool_name: tool,
90			input,
91			tool_use_id: toolUseId,
92			description: reason,
93			permission_suggestions: suggestions
94		}
95	};
96}
97
98/**
99 * The files the server saved for a chat prompt (`workbench_attachments`);
100 * `undefined` from a server that only puts them in the text as `@` mentions.
101 */
102export function attachedFiles(line: Line): string[] | undefined {
103	const files = line.workbench_attachments;
104	return Array.isArray(files)
105		? files.filter((f): f is string => typeof f === 'string' && f.length > 0)
106		: undefined;
107}
108
109/**
110 * A prompt naming files to Read: a plugin's prompt never has its `@` mentions
111 * (chat images and files) expanded, submitted or appended. Only the files the
112 * server attached are listed; `@scope/pkg` in prose is no file. Without that
113 * list (an older server) every mention counts, as it did.
114 */
115export function withAttachments(text: string, files?: readonly string[]): string {
116	const listed =
117		files ?? [...text.matchAll(/(?:^|\s)@(?:"([^"]+)"|(\S+))/g)].map((m) => m[1] ?? m[2]);
118	return listed.length ? `${text}${ATTACHED_FILES}${listed.map((f) => `- ${f}`).join('\n')}` : text;
119}
120
121/** `/name args` as `$.command.run` takes it; a path like `/Users/x` is no command. */
122export function slashCommand(text: string): { command: string; args: string } | undefined {
123	const m = /^\/([\w:.-]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
124	return m ? { command: m[1], args: m[2]?.trim() ?? '' } : undefined;
125}
126
127/** A chat prompt put into a running turn, framed as the CLI frames a typed one. */
128export function midTurn(text: string, files?: readonly string[]): string {
129	return `${QUEUED_PREFIX}${withAttachments(text, files)}\n\nIMPORTANT: After completing your current task, you MUST address the user's message above. Do not ignore it.`;
130}
131
132/**
133 * One `/mod/ask` reply: the answer, `null` to fall back to the terminal (no
134 * chat open), or `undefined` to keep waiting.
135 */
136export function askAnswer(text: string): Answer | null | undefined {
137	const reply = JSON.parse(text) as {
138		answer?: { response?: { subtype?: string; response?: Answer; error?: string } };
139		fallback?: boolean;
140	};
141	if (reply.fallback) return null;
142	const response = reply.answer?.response;
143	if (!response) return undefined;
144	return response.subtype === 'error'
145		? { behavior: 'deny', message: response.error }
146		: (response.response ?? { behavior: 'deny' });
147}
148
149const unixSeconds = (iso: string | undefined) => {
150	const ms = iso ? Date.parse(iso) : NaN;
151	return Number.isNaN(ms) ? undefined : Math.floor(ms / 1000);
152};
153
154/**
155 * The most used rate-limit window, as the SDK's `rate_limit_event` reports one,
156 * plus every window (`windows`): the server answers plan usage from them.
157 */
158export function rateLimitLine(windows: readonly SessionRateLimit[]): Line | undefined {
159	const top = [...windows].sort((a, b) => b.percentUsed - a.percentUsed)[0];
160	if (!top) return undefined;
161	// The SDK warns past its own thresholds; 90% is where the chat starts to.
162	const status =
163		top.percentUsed >= 100 ? 'rejected' : top.percentUsed >= 90 ? 'allowed_warning' : 'allowed';
164	const resetsAt = unixSeconds(top.resetsAt);
165	return {
166		type: 'rate_limit_event',
167		rate_limit_info: {
168			status,
169			rateLimitType: top.kind,
170			utilization: top.percentUsed / 100,
171			...(resetsAt === undefined ? {} : { resetsAt })
172		},
173		windows: windows.map((w) => ({
174			kind: w.kind,
175			percentUsed: w.percentUsed,
176			resetsAt: unixSeconds(w.resetsAt)
177		}))
178	};
179}
180
181/** A task status as the SDK's `task_notification` names an end, or `running`. */
182export function taskStatus(status: string): string {
183	if (status === 'running' || status === 'pending') return 'running';
184	if (status === 'killed' || status === 'stopped') return 'stopped';
185	return status === 'failed' ? 'failed' : 'completed';
186}
187
188/** The task and outcome a `<task-notification>` delivery names. */
189export function taskNotification(
190	text: string
191): { id: string; status: string; summary?: string } | undefined {
192	const tag = (name: string) => new RegExp(`<${name}>([^<]*)</${name}>`).exec(text)?.[1]?.trim();
193	const id = tag('task-id');
194	const status = tag('status');
195	return id && status ? { id, status: taskStatus(status), summary: tag('summary') } : undefined;
196}
197