Reports Claude Code session activity to the Workbench desktop app

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.
The public landing page lives in site/ and deploys to GitHub Pages. See landing page development.
Download the latest release from the Releases page:
Workbench_x.x.x_universal.dmgOpen the .dmg and drag Workbench to your Applications folder.
macOS is the primary target. Linux and Windows support is planned.
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.
portable-pty + xterm.js~/.workbench/portable-pty (terminal emulation)bun install
bun run dev
This runs tauri dev, which starts the Vite dev server on port 1420 and opens the Tauri window.
| Command | Description |
|---|---|
bun install | Install dependencies |
bun run dev | Start dev server + Tauri window |
bun run build | Build production .app and .dmg |
bun run check | Run svelte-check type checking |
bun run lint | Run Prettier + ESLint checks |
bun run format | Auto-format with Prettier |
bun run test | Run all tests (frontend + Rust) |
bun run test:unit | Run frontend unit tests only |
bun run test:component | Run frontend component tests only |
Rust tests can be run separately:
cargo test --manifest-path src-tauri/Cargo.toml
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
This project is licensed under the GNU Affero General Public License v3.0.
hooks/register.ts 96 lines1import 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};
96hooks/chat.ts 1250 lines1import 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 lines1import 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}
353hooks/asks.ts 57 lines1// 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}
57hooks/jobs.ts 47 lines1import { 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}
47hooks/titles.ts 187 lines1// 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}
187hooks/lines.ts 197 lines1import 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