@dalmasonto/taskflow-mcp 2.13.1 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -14,12 +14,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
14
  import { z } from "zod";
15
15
  import { loadConfigFile, findConfigPath, resolveProfile, resolveProfileOrAsk, } from "./config.js";
16
16
  import { TaskflowClient, TaskflowApiError } from "./client.js";
17
- import { renderInstructions } from "./agent-instructions.js";
17
+ import { renderInstructions, renderScopeNotice } from "./agent-instructions.js";
18
+ import { agentsManageFailure } from "./agents-manage.js";
18
19
  import { resolveAttachments } from "./attachments.js";
19
20
  import { getMirrorStatus } from "./mirror.js";
20
21
  import { downloadAttachment } from "./attachment-download.js";
21
22
  import { detectTmuxPane } from "./tmux.js";
22
23
  import { AGENT_INSTRUCTIONS } from "./instructions.js";
24
+ import { diffLines, installScript, installedStatus, uninstallScript, } from "./toolbox.js";
23
25
  import { sessionIdentifier } from "./session-identifier.js";
24
26
  import { getConnectionStatus } from "./connect.js";
25
27
  import { selectProfile } from "./runtime.js";
@@ -48,6 +50,17 @@ function fail(err) {
48
50
  : String(err);
49
51
  return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
50
52
  }
53
+ /// #605: the failure of an agent-management call. A bare 403 would read as "not
54
+ /// allowed" with no way forward, and the way forward is specific — and DIFFERENT
55
+ /// for the two 403s (missing scope vs. self-revoke). See `agentsManageFailure`.
56
+ function failAgentsManage(err) {
57
+ if (err instanceof TaskflowApiError) {
58
+ const message = agentsManageFailure(err);
59
+ if (message)
60
+ return { content: [{ type: "text", text: message }], isError: true };
61
+ }
62
+ return fail(err);
63
+ }
51
64
  /**
52
65
  * The refusal returned instead of guessing an identity.
53
66
  *
@@ -119,10 +132,77 @@ export function collisionWarning(profileName, live) {
119
132
  `identity share one inbox and one read cursor, so messages meant for one will ` +
120
133
  `be marked read by the other. Tell your human before you continue.`);
121
134
  }
122
- /**
123
- * Construct the MCP server with all tools registered. The config is loaded
124
- * eagerly so a broken `.taskflow.json` fails fast at startup with a clear error.
125
- */
135
+ /// The label a vetting record carries: the agent's own display name, so a
136
+ /// teammate reading "vetted by Build 3" knows who to ask.
137
+ async function agentLabelFor(client) {
138
+ try {
139
+ const identity = (await client.whoami());
140
+ return identity?.display_name ?? identity?.agent?.display_name ?? "an agent";
141
+ }
142
+ catch {
143
+ return "an agent";
144
+ }
145
+ }
146
+ async function toolboxIndexFor(client, projectRoot) {
147
+ try {
148
+ const listed = (await client.toolboxList());
149
+ const entries = (listed?.entries ?? []);
150
+ const scripts = entries.filter((entry) => entry.kind === "script");
151
+ const statuses = scripts.length
152
+ ? await installedStatus({
153
+ projectRoot,
154
+ serverScripts: scripts.map((script) => ({ name: script.name, version: script.version })),
155
+ })
156
+ : [];
157
+ const byName = new Map(statuses.map((status) => [status.name, status]));
158
+ return {
159
+ count: entries.length,
160
+ entries: entries.map((entry) => {
161
+ const status = byName.get(entry.name);
162
+ return {
163
+ name: entry.name,
164
+ kind: entry.kind,
165
+ description: entry.description,
166
+ status: entry.status,
167
+ version: entry.version,
168
+ install_state: status?.state ?? "n/a",
169
+ installed_version: status?.installed_version ?? null,
170
+ local_edit: status?.local_edit ?? false,
171
+ };
172
+ }),
173
+ };
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ }
179
+ /** The readable half of the index: what an agent reads without calling anything
180
+ * else. Kept SHORT — entries are one line each, and code never appears. */
181
+ function renderToolboxIndex(index) {
182
+ if (!index || index.count === 0)
183
+ return null;
184
+ const lines = [
185
+ `Project toolbox — ${index.count} entr${index.count === 1 ? "y" : "ies"} already saved for this project:`,
186
+ ];
187
+ for (const entry of index.entries.slice(0, 24)) {
188
+ const marker = entry.kind === "memory"
189
+ ? "[memory]"
190
+ : entry.install_state === "current"
191
+ ? `[installed v${entry.installed_version}]`
192
+ : entry.install_state === "update-available"
193
+ ? `[installed v${entry.installed_version}, update available: v${entry.version}]`
194
+ : entry.install_state === "locally-edited"
195
+ ? `[installed v${entry.installed_version}, EDITED LOCALLY — save the fix with toolbox_save or reinstall]`
196
+ : "[script, not installed]";
197
+ const status = entry.status !== "approved" ? ` (${entry.status})` : "";
198
+ lines.push(`- ${entry.name} ${marker}${status} — ${entry.description}`);
199
+ }
200
+ if (index.entries.length > 24) {
201
+ lines.push(`…and ${index.entries.length - 24} more: call toolbox_list.`);
202
+ }
203
+ lines.push("Read one with toolbox_get (nothing loads code into your context until you ask). Check the toolbox BEFORE building tooling, and save what works.");
204
+ return lines.join("\n");
205
+ }
126
206
  export function buildServer(options = {}) {
127
207
  const configPath = findConfigPath({
128
208
  configPath: options.configPath,
@@ -254,6 +334,12 @@ export function buildServer(options = {}) {
254
334
  return picked.refusal;
255
335
  const { client } = picked;
256
336
  const identity = await client.whoami();
337
+ // #617: the toolbox index at connect. Progressive disclosure is the
338
+ // whole point: names and one-line descriptions (never code), plus what
339
+ // is already installed locally and what has a newer version — so the
340
+ // agent knows what exists and what it can run WITHOUT loading any of
341
+ // it. A toolbox that is unreachable must not break whoami.
342
+ const toolbox = await toolboxIndexFor(client, dirname(configPath));
257
343
  // The mirror's health rides along with identity because a dead mirror
258
344
  // is otherwise invisible: it only ever wrote one line to stderr, which
259
345
  // nobody reads, so "the dashboard terminal is stale" could only be
@@ -262,12 +348,20 @@ export function buildServer(options = {}) {
262
348
  ...identity,
263
349
  connection: getConnectionStatus(),
264
350
  mirror: getMirrorStatus(),
351
+ ...(toolbox ? { toolbox } : {}),
265
352
  });
266
353
  // #615/#616: the JSON escapes every newline, so long markdown is
267
- // unreadable there. Show it again as plain text when there is any.
268
- const readable = renderInstructions(identity);
354
+ // unreadable there. Show it again as plain text when there is any —
355
+ // #605's scope notice rides in the same block, because both change what
356
+ // the agent should DO next.
357
+ const readable = [renderInstructions(identity), renderScopeNotice(identity)]
358
+ .filter((part) => !!part)
359
+ .join("\n\n");
269
360
  if (readable)
270
361
  result.content.push({ type: "text", text: readable });
362
+ const toolboxText = renderToolboxIndex(toolbox);
363
+ if (toolboxText)
364
+ result.content.push({ type: "text", text: toolboxText });
271
365
  return result;
272
366
  }
273
367
  catch (err) {
@@ -365,6 +459,79 @@ export function buildServer(options = {}) {
365
459
  return fail(err);
366
460
  }
367
461
  });
462
+ // ---- agent management (#605) ---------------------------------------------
463
+ // These three need `agents:manage` on YOUR key. Without it the server answers
464
+ // 403 and the human must mint a scoped key from the dashboard's Connect-agents
465
+ // page — so the error text says exactly that instead of leaving a bare 403.
466
+ server.tool("create_agent", "Mint another agent in this project and return its key ONCE — use it to set up a worker without asking your human to link it. Requires the agents:manage scope on YOUR OWN key; the key you mint never carries a scope (only a human owner/admin can grant one).", {
467
+ display_name: z.string().min(1).describe("How the new agent appears in chat, on tasks and in activity."),
468
+ agent_profile: z
469
+ .string()
470
+ .min(1)
471
+ .describe("Its profile name inside .taskflow.json (lowercase letters, digits, - or _; e.g. reviewer)."),
472
+ instructions_markdown: z
473
+ .string()
474
+ .optional()
475
+ .describe("Its role instructions, applied at creation. It reads them through whoami."),
476
+ project_root: z.string().optional().describe("The repo the new agent will run in (absolute path)."),
477
+ runtime: z.string().optional().describe("Its runtime, e.g. claude-code."),
478
+ version: z.string().optional().describe("Its runtime version."),
479
+ ...profileArg,
480
+ }, async ({ display_name, agent_profile, instructions_markdown, project_root, runtime, version, profile }) => {
481
+ try {
482
+ const picked = await clientFor(profile);
483
+ if (!picked.ok)
484
+ return picked.refusal;
485
+ const { client } = picked;
486
+ const minted = await client.createAgent({
487
+ display_name,
488
+ profile: agent_profile,
489
+ ...(instructions_markdown !== undefined ? { instructions_markdown } : {}),
490
+ ...(project_root !== undefined ? { project_root } : {}),
491
+ ...(runtime !== undefined ? { runtime } : {}),
492
+ ...(version !== undefined ? { version } : {}),
493
+ });
494
+ return ok({
495
+ ...minted,
496
+ next: "The key is shown once — save it in the new agent's .taskflow.json now. It is an ordinary key: it cannot mint agents.",
497
+ });
498
+ }
499
+ catch (err) {
500
+ return failAgentsManage(err);
501
+ }
502
+ });
503
+ server.tool("set_agent_instructions", "Replace another agent's role instructions in this project (it picks them up on its next whoami, no new key, no restart). Requires the agents:manage scope on your key. Pass null to clear them.", {
504
+ agent: z.number().int().describe("The agent's id (from list_agents)."),
505
+ markdown: z.string().nullable().describe("The markdown role instructions, or null to clear them."),
506
+ ...profileArg,
507
+ }, async ({ agent, markdown, profile }) => {
508
+ try {
509
+ const picked = await clientFor(profile);
510
+ if (!picked.ok)
511
+ return picked.refusal;
512
+ const { client } = picked;
513
+ return ok(await client.setPeerInstructions(agent, markdown));
514
+ }
515
+ catch (err) {
516
+ return failAgentsManage(err);
517
+ }
518
+ });
519
+ server.tool("delete_agent", "Revoke another agent in this project: its keys, sessions and rosters go with it, so its key stops working at once; what it said (messages, reviews, task authorship) stays under its name. Requires the agents:manage scope, and an agent can never delete itself — ask your human for that.", {
520
+ agent: z.number().int().describe("The agent's id (from list_agents)."),
521
+ ...profileArg,
522
+ }, async ({ agent, profile }) => {
523
+ try {
524
+ const picked = await clientFor(profile);
525
+ if (!picked.ok)
526
+ return picked.refusal;
527
+ const { client } = picked;
528
+ await client.deletePeer(agent);
529
+ return ok({ deleted: agent });
530
+ }
531
+ catch (err) {
532
+ return failAgentsManage(err);
533
+ }
534
+ });
368
535
  // ---- tasks ----
369
536
  server.tool("create_task", "Create a task in the agent's project. Optionally self-claim it. Returns the created task.", {
370
537
  title: z.string().min(1).describe("Short task title."),
@@ -375,12 +542,16 @@ export function buildServer(options = {}) {
375
542
  .describe("Task priority (default: normal)."),
376
543
  notes: z.string().optional().describe("Markdown notes."),
377
544
  claim: z.boolean().optional().describe("If true, assign the new task to this agent."),
545
+ repo: z
546
+ .string()
547
+ .optional()
548
+ .describe("Which of the project's linked GitHub repos this task belongs to (owner/name). Omit for the project's default. Publish uses this repo, and an issue number is only meaningful with it."),
378
549
  files: z
379
550
  .array(z.string())
380
551
  .optional()
381
552
  .describe("Paths to attach to the new task, relative to the project root (or absolute, inside it). Max 25MB each."),
382
553
  ...profileArg,
383
- }, async ({ title, description, notes, priority, claim, files, profile }) => {
554
+ }, async ({ title, description, notes, priority, claim, files, repo, profile }) => {
384
555
  try {
385
556
  const picked = await clientFor(profile);
386
557
  if (!picked.ok)
@@ -392,6 +563,7 @@ export function buildServer(options = {}) {
392
563
  notes_markdown: notes,
393
564
  priority,
394
565
  claim,
566
+ repo,
395
567
  }));
396
568
  if (!files?.length)
397
569
  return ok(task);
@@ -438,8 +610,13 @@ export function buildServer(options = {}) {
438
610
  .array(z.string())
439
611
  .optional()
440
612
  .describe("Paths to attach to this task, relative to the project root (or absolute, inside it). Max 25MB each. Attaching is additive — it never removes existing attachments."),
613
+ repo: z
614
+ .string()
615
+ .nullable()
616
+ .optional()
617
+ .describe("Move the task to one of the project's linked GitHub repos (owner/name), or pass null to put it back on the project default. Omit to leave it as it is."),
441
618
  ...profileArg,
442
- }, async ({ task, title, description, notes, priority, files, profile }) => {
619
+ }, async ({ task, title, description, notes, priority, files, repo, profile }) => {
443
620
  try {
444
621
  const picked = await clientFor(profile);
445
622
  if (!picked.ok)
@@ -450,9 +627,10 @@ export function buildServer(options = {}) {
450
627
  const hasFields = title !== undefined ||
451
628
  description !== undefined ||
452
629
  notes !== undefined ||
453
- priority !== undefined;
630
+ priority !== undefined ||
631
+ repo !== undefined;
454
632
  if (!hasFields && !files?.length) {
455
- return fail(new Error("Nothing to update: pass at least one of title, description, notes, priority or files."));
633
+ return fail(new Error("Nothing to update: pass at least one of title, description, notes, priority, repo or files."));
456
634
  }
457
635
  let updated = undefined;
458
636
  if (hasFields) {
@@ -461,6 +639,7 @@ export function buildServer(options = {}) {
461
639
  description_markdown: description,
462
640
  notes_markdown: notes,
463
641
  priority,
642
+ repo,
464
643
  });
465
644
  }
466
645
  if (!files?.length)
@@ -568,8 +747,37 @@ export function buildServer(options = {}) {
568
747
  .boolean()
569
748
  .optional()
570
749
  .describe("Ignored for placement — the destination channel decides where a message lands; kept only for compatibility. Design has its own room (one per project: the channel with is_design true in list_channels), so post design replies there."),
750
+ questions: z
751
+ .object({
752
+ title: z.string().optional().describe("Heading for the whole form."),
753
+ questions: z
754
+ .array(z.object({
755
+ key: z
756
+ .string()
757
+ .describe("Stable id for the answer, unique in this set (letters, digits, - _ :), max 40."),
758
+ label: z.string().describe("The question as the person reads it."),
759
+ type: z.enum(["radio", "select", "text", "date"]),
760
+ options: z
761
+ .array(z.object({
762
+ value: z.string().describe("What comes back as the answer."),
763
+ label: z.string(),
764
+ description: z.string().optional().describe("A supporting line under the option."),
765
+ }))
766
+ .optional()
767
+ .describe("radio/select: at least 2 required."),
768
+ allow_other: z.boolean().optional().describe("radio/select: offer an 'Other…' text input."),
769
+ required: z.boolean().optional().describe("The person cannot submit until it is answered."),
770
+ placeholder: z.string().optional().describe("text/date only."),
771
+ multiline: z.boolean().optional().describe("text only: a taller box for prose answers."),
772
+ }))
773
+ .describe("1-8 questions."),
774
+ submit_label: z.string().optional(),
775
+ cancel_label: z.string().optional(),
776
+ })
777
+ .optional()
778
+ .describe("A structured question set rendered as a form in the chat — prefer this over asking several things in prose: the person gets radio cards, a select, text and date inputs, and you get ONE readable reply. The answers arrive as a REPLY to this message, one bullet per question in the form `- <label> (<key>): <answer>`; a person who declines sends a reply starting with `Cancelled the question:`. Do not re-send a set that already has a reply."),
571
779
  ...profileArg,
572
- }, async ({ channel, body, reply_to, priority, files, is_design, profile }) => {
780
+ }, async ({ channel, body, reply_to, priority, files, is_design, questions, profile }) => {
573
781
  try {
574
782
  const picked = await clientFor(profile);
575
783
  if (!picked.ok)
@@ -584,6 +792,7 @@ export function buildServer(options = {}) {
584
792
  priority,
585
793
  is_design,
586
794
  attachments,
795
+ questions,
587
796
  ...(reply_to === undefined ? {} : { reply_to }),
588
797
  }));
589
798
  }
@@ -899,6 +1108,111 @@ export function buildServer(options = {}) {
899
1108
  return fail(err);
900
1109
  }
901
1110
  });
1111
+ server.tool("design_rename_route", "Move a page's ROUTE — `/ai-coach` becomes `/pulse` — with every reference following it: the page file, links to it inside other pages (rewritten through the same validator, one recorded write each), the flow order, group memberships, the page's label, both ends of every arrow, and its pinned Flow position. The old page goes to the TRASH (restore it from the Pages panel if the rename was wrong); nothing else is deleted, and the response names what changed — including any page that could not be rewritten because someone edited it concurrently. This is not a label change: for the NAME the Pages panel shows, use design_arrange's set_label instead, which touches no route and no link.", {
1112
+ from: z.string().min(1).describe("The route as it is now, e.g. '/ai-coach'."),
1113
+ to: z.string().min(1).describe("The route it should become, e.g. '/pulse'. Must not already serve a page."),
1114
+ ...designProjectArg,
1115
+ ...profileArg,
1116
+ }, async ({ from, to, project, profile }) => {
1117
+ try {
1118
+ const picked = await clientFor(profile);
1119
+ if (!picked.ok)
1120
+ return picked.refusal;
1121
+ const { client } = picked;
1122
+ return ok(await client.designRenameRoute(await resolveDesignProject(client, project), from, to));
1123
+ }
1124
+ catch (err) {
1125
+ return fail(err);
1126
+ }
1127
+ });
1128
+ server.tool("design_list_versions", "Every recorded version of ONE design file, newest first — #520's history. `path` is a file path (`pages/index.html`, `components/app-header.js`, `styles/tokens.json`). Each entry carries `version` (the file version to pass to design_read_version / design_restore_version), `project_revision` (its place in the project timeline), `bytes`, `updated_by`, and `deleted` (a trash is recorded too). Content is NOT included: read the version you want. Use this before assuming an old design is gone — a page's last 20 versions are kept, along with the tokens and components they rendered with.", {
1129
+ path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
1130
+ ...designProjectArg,
1131
+ ...profileArg,
1132
+ }, async ({ path, project, profile }) => {
1133
+ try {
1134
+ const picked = await clientFor(profile);
1135
+ if (!picked.ok)
1136
+ return picked.refusal;
1137
+ const { client } = picked;
1138
+ return ok(await client.designVersions(await resolveDesignProject(client, project), path));
1139
+ }
1140
+ catch (err) {
1141
+ return fail(err);
1142
+ }
1143
+ });
1144
+ server.tool("design_read_version", "One recorded version's content, with `preview_revision` — the project revision to render it at, so the version can be SEEN with the tokens and components of its time (the sandbox takes `?rev=<preview_revision>`, and design_read_page/design_write_page work on the LIVE file). Reach for this when the owner says \"bring back how the pricing page looked last week\": list the versions, read the one they mean, then design_restore_version it.", {
1145
+ path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
1146
+ version: z.number().int().describe("The file version from design_list_versions."),
1147
+ ...designProjectArg,
1148
+ ...profileArg,
1149
+ }, async ({ path, version, project, profile }) => {
1150
+ try {
1151
+ const picked = await clientFor(profile);
1152
+ if (!picked.ok)
1153
+ return picked.refusal;
1154
+ const { client } = picked;
1155
+ return ok(await client.designVersion(await resolveDesignProject(client, project), path, version));
1156
+ }
1157
+ catch (err) {
1158
+ return fail(err);
1159
+ }
1160
+ });
1161
+ server.tool("design_restore_version", "Bring an old version back (#520). This is a NEW WRITE, not a rewind: the old content is written as a new version through the normal validator, so the history stays complete and the restore can itself be undone by restoring the version it replaced. Read the version first (design_read_version) when it matters what is in it — the restore does not ask. Answers with the new version and a `restored_from`.", {
1162
+ path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
1163
+ version: z.number().int().describe("The file version to bring back."),
1164
+ ...designProjectArg,
1165
+ ...profileArg,
1166
+ }, async ({ path, version, project, profile }) => {
1167
+ try {
1168
+ const picked = await clientFor(profile);
1169
+ if (!picked.ok)
1170
+ return picked.refusal;
1171
+ const { client } = picked;
1172
+ return ok(await client.designRestoreVersion(await resolveDesignProject(client, project), path, version));
1173
+ }
1174
+ catch (err) {
1175
+ return fail(err);
1176
+ }
1177
+ });
1178
+ server.tool("design_search", "Find what the design SAYS, without reading every page: one call answers \"which pages mention this term, and where\". USE THIS FIRST for any rename, audit or copy review (e.g. renaming \"AI coach\" to \"Pulse\"): search, then design_read_page / design_write_page ONLY the pages it returns. Never read every page to find a term. " +
1179
+ "Searches stored design files server-side, not raw HTML: `text` (default scope) matches visible text runs; `attributes` matches the accessibility attributes a rename must not miss (alt, title, aria-label, placeholder, value); `code` matches class names, ids, data-* and scripts and is OFF unless you ask for it, so \"coach\" inside class=\"coach-card\" is not reported as copy; `page_names` matches page titles and Pages-panel labels; `groups` and `flow_labels` match the arrangement. Components are searched whole and reported ONCE with `used_by_routes` — one edit to a component fixes every route that uses it, so do not treat those routes as separate edits. " +
1180
+ "Matching is whole-word by default (`whole_word: false` to match inside words, e.g. \"coach\" inside \"coaching\") and case-insensitive (`case_sensitive: true` to pin it). `regex: true` treats the query as a Rust regex. " +
1181
+ "The answer is `total` (matches across the whole project), `variants` (each surface form with its count — a rename that ignores a variant leaves it behind), `files` (kind, route/name, file, version, count, and per-hit `what` + `line` + a `snippet` with the hit wrapped in <<>>), `labels` (matches that live in the arrangement, not in a file), and `next_cursor` when more files remain: pass it back as `cursor`. Each file's `version` is what a write's `base_version` takes.", {
1182
+ query: z.string().min(1).describe("The term to find, e.g. 'AI coach'."),
1183
+ regex: z.boolean().optional().describe("Treat the query as a Rust regex (default false)."),
1184
+ case_sensitive: z.boolean().optional().describe("Default false: matching ignores case."),
1185
+ whole_word: z.boolean().optional().describe("Default TRUE: 'coach' does not match 'coaching'. Pass false for substring matching."),
1186
+ scope: z
1187
+ .string()
1188
+ .optional()
1189
+ .describe("Comma-separated: text (default), attributes, code, page_names, groups, flow_labels."),
1190
+ in: z.enum(["pages", "components", "both"]).optional().describe("Where to search (default both)."),
1191
+ cursor: z.string().optional().describe("next_cursor from the previous call; files after it are returned."),
1192
+ limit: z.number().int().min(1).max(100).optional().describe("Files per page (default 25, max 100)."),
1193
+ ...designProjectArg,
1194
+ ...profileArg,
1195
+ }, async ({ query, regex, case_sensitive, whole_word, scope, in: where, cursor, limit, project, profile }) => {
1196
+ try {
1197
+ const picked = await clientFor(profile);
1198
+ if (!picked.ok)
1199
+ return picked.refusal;
1200
+ const { client } = picked;
1201
+ return ok(await client.designSearch(await resolveDesignProject(client, project), {
1202
+ query,
1203
+ regex,
1204
+ caseSensitive: case_sensitive,
1205
+ wholeWord: whole_word,
1206
+ scope,
1207
+ in: where,
1208
+ cursor,
1209
+ limit,
1210
+ }));
1211
+ }
1212
+ catch (err) {
1213
+ return fail(err);
1214
+ }
1215
+ });
902
1216
  server.tool("design_read_layout", "Read how the project's PAGES ARE ARRANGED: the named page groups and what each one holds, the flow (the order the pages are presented in and the canvas draws in), and the name each page is listed under. design_list_components returns the registry as a FLAT array with no group and no order, so the arrangement cannot be recovered from it — if you need to know how pages are grouped, or in which order they come, ask THIS. Nothing to select: the arrangement is one document per project, so there is no route, group id or name to pass. `view` is the canvas arrangement (rows/bands/groups/flow — `flow` is the user-flow canvas) and the grouping reads the same in every one. `version` is THIS arrangement's version — hand it to a layout write as `base_version` to be told if someone rearranged the board under you. (Note `revision` next to it is a different number: the manifest's, which moves when a page changes.) It also returns the USER FLOW drawn as arrows on the Flow canvas: `edges` is a list of `{id, from, to, label?}` — a user goes from route `from` to route `to`, and the optional label names the path (\"new user\" / \"existing user\") — and `positions` maps a route to its fixed `{x, y}` node on that canvas (an unlisted page is auto-placed). Arrange the board with design_create_group, design_update_group, design_reorder_group, design_reorder_page and design_delete_group; draw the flow with design_link_pages and design_unlink_pages — or several edits at once with design_arrange (which also takes update_link and place_page) — all of which take operations — never PUT a document built from this response back, because this is the panel's view and not the stored form.",
903
1217
  // The arrangement is one document per project, so there is nothing to
904
1218
  // select: a route or a group id would be an argument this read has no use
@@ -1131,6 +1445,24 @@ export function buildServer(options = {}) {
1131
1445
  // tools make, in the route's own wire shape (plus #508's update_link and
1132
1446
  // place_page, which exist only here).
1133
1447
  const coord = z.number().finite();
1448
+ /** #525: the text a delivered render contributes — where it went, and the
1449
+ * reminder that the pixels are NOT in this reply. */
1450
+ const deliveredNote = (delivered) => {
1451
+ if (!delivered || typeof delivered !== "object")
1452
+ return "";
1453
+ const record = delivered;
1454
+ if (record.to === "message") {
1455
+ const count = Array.isArray(record.attachments) ? record.attachments.length : 0;
1456
+ return `\n\nDELIVERED to channel ${record.channel} as message ${record.message_id} (${count} attachment(s)). The image is NOT in this reply — the human has it; do not re-send it.`;
1457
+ }
1458
+ if (record.to === "url") {
1459
+ const urls = Array.isArray(record.images)
1460
+ ? record.images.map((image) => image.url).filter(Boolean).join(", ")
1461
+ : "";
1462
+ return `\n\nDELIVERED as file(s): ${urls}. The image is NOT in this reply.`;
1463
+ }
1464
+ return "";
1465
+ };
1134
1466
  const layoutOpSchema = z.union([
1135
1467
  z.object({ create_group: z.object({ name: z.string() }).strict() }).strict(),
1136
1468
  z.object({ update_group: z.object({ group_id: z.string(), name: z.string() }).strict() }).strict(),
@@ -1176,7 +1508,7 @@ export function buildServer(options = {}) {
1176
1508
  .object({ place_page: z.object({ route: z.string().min(1), x: coord, y: coord }).strict() })
1177
1509
  .strict(),
1178
1510
  ]);
1179
- server.tool("design_arrange", "Apply SEVERAL arrangement edits as ONE write: `ops` is an ordered list, each item exactly one of {\"create_group\":{name}}, {\"update_group\":{group_id,name}}, {\"reorder_group\":{group_id,position}}, {\"reorder_page\":{route,group_id?,position?}}, {\"delete_group\":{group_id}}, and for the USER FLOW on the Flow canvas (arrows showing how a user moves between screens): {\"link_pages\":{from,to,label?}}, {\"unlink_pages\":{edge_id} or {from,to}}, {\"update_link\":{edge_id,label?}} (relabel an arrow; omit label to clear it), {\"place_page\":{route,x,y}} (pin a page's node at canvas coordinates, clamped to ±100000; this is NOT reorder_page, which moves a page in the list) — the same rules as the single tools of those names. Use it to file many pages into groups, reorder a whole section, or lay out a whole flow (link the screens and place each node) in one call. Link ids are minted by the server and reported in `changed.edges`; placed routes in `changed.positions`. The ops run in order, each on the result of the one before, and the batch is ALL OR NOTHING: if any op is refused, none is stored and the error names the failing index (`ops[2] (reorder_page) ...`). The whole batch is one version. A group created in the batch gets its id minted by the server, so a later op in the SAME batch cannot name it — create first, then arrange with the id from `changed.groups`. 1 to 100 ops; a one-item list behaves exactly like the single tool. " +
1511
+ server.tool("design_arrange", "Apply SEVERAL arrangement edits as ONE write: `ops` is an ordered list, each item exactly one of {\"create_group\":{name}}, {\"update_group\":{group_id,name}}, {\"reorder_group\":{group_id,position}}, {\"reorder_page\":{route,group_id?,position?}}, {\"delete_group\":{group_id}}, and for the USER FLOW on the Flow canvas (arrows showing how a user moves between screens): {\"link_pages\":{from,to,label?}}, {\"unlink_pages\":{edge_id} or {from,to}}, {\"update_link\":{edge_id,label?}} (relabel an arrow; omit label to clear it), {\"place_page\":{route,x,y}} (pin a page's node at canvas coordinates, clamped to ±100000; this is NOT reorder_page, which moves a page in the list), and {\"set_label\":{route,label}} (name a page for the Pages panel; omit label (or send an empty string) to clear it back to the page's own title — bulk renaming a set of pages is a batch of these) — the same rules as the single tools of those names. Use it to file many pages into groups, reorder a whole section, or lay out a whole flow (link the screens and place each node) in one call. Link ids are minted by the server and reported in `changed.edges`; placed routes in `changed.positions`. The ops run in order, each on the result of the one before, and the batch is ALL OR NOTHING: if any op is refused, none is stored and the error names the failing index (`ops[2] (reorder_page) ...`). The whole batch is one version. A group created in the batch gets its id minted by the server, so a later op in the SAME batch cannot name it — create first, then arrange with the id from `changed.groups`. 1 to 100 ops; a one-item list behaves exactly like the single tool. " +
1180
1512
  baseVersionNote, {
1181
1513
  ...designProjectArg,
1182
1514
  ops: z.array(layoutOpSchema).min(1).max(100).describe("The edits, applied in order as one all-or-nothing write."),
@@ -1303,7 +1635,7 @@ export function buildServer(options = {}) {
1303
1635
  return fail(err);
1304
1636
  }
1305
1637
  });
1306
- server.tool("design_write_asset", "Write one file that is NOT a page, a component or the token scale. Two shapes: `assets/<name>` — an image file (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.ico`; a bare name like 'logo.svg' means assets/logo.svg) — or `styles/resources.json`, the external-resources document naming the project's web fonts and external script links, which design_write_tokens does NOT write. THIS is where a webfont is loaded: its enabled sets go into the head of EVERY page, so a typeface is one token (typography.font-sans) plus this one file — never a <link> per page. Shape: {\"version\":1,\"sets\":[{\"id\":\"font\",\"name\":\"Font\",\"enabled\":true,\"links\":[{\"rel\":\"preconnect\",\"href\":\"https://cdn.jsdelivr.net\",\"crossorigin\":true},{\"rel\":\"stylesheet\",\"href\":\"https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5/index.css\"}]}]} (https only; rel stylesheet/preconnect/dns-prefetch). design_get_tokens returns the current document and its version in `resources`. The operator often manages its sets from the panel: when it exists, change only what you need (usually `enabled` flags) and keep every other set as is. Content is TEXT, exactly like every other design write: an SVG goes in as its own markup, and raster bytes as `data:<image/png>;base64,<payload>`, which the server decodes when the file is served. An asset is served from the sandbox origin at `/s/{token}/f/assets/<name>` with its own content type — but nothing rewrites an `src=` into a sandbox URL the way links are rewritten, so a page cannot point at it by a relative path: for an image a page shows today use an https URL or a data: URI inside the fragment. Caps unchanged: 128 KB per file (rule `size-cap`), and a NEW file must also fit the project's 200-file / 4 MB budget. Pass base_version only when replacing an existing file.", {
1638
+ server.tool("design_write_asset", "Write one file that is NOT a page, a component or the token scale. Two shapes: `assets/<name>` — an image file (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.ico`; a bare name like 'logo.svg' means assets/logo.svg) — or `styles/resources.json`, the external-resources document naming the project's web fonts and external script links, which design_write_tokens does NOT write. THIS is where a webfont is loaded: its enabled sets go into the head of EVERY page, so a typeface is one token (typography.font-sans) plus this one file — never a <link> per page. Shape: {\"version\":1,\"sets\":[{\"id\":\"font\",\"name\":\"Font\",\"enabled\":true,\"links\":[{\"rel\":\"preconnect\",\"href\":\"https://cdn.jsdelivr.net\",\"crossorigin\":true},{\"rel\":\"stylesheet\",\"href\":\"https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5/index.css\"}]}]} (https only; rel stylesheet/preconnect/dns-prefetch). design_get_tokens returns the current document and its version in `resources`. The operator often manages its sets from the panel: when it exists, change only what you need (usually `enabled` flags) and keep every other set as is. Content is TEXT, exactly like every other design write: an SVG goes in as its own markup, and raster bytes as `data:<image/png>;base64,<payload>`, which the server decodes when the file is served. An asset is served from the sandbox origin at `/s/{token}/f/assets/<name>` with its own content type — but nothing rewrites an `src=` into a sandbox URL the way links are rewritten, so a page cannot point at it by a relative path: for an image a page shows today use an https URL or a data: URI inside the fragment. Caps unchanged: 128 KB per file (rule `size-cap`), and a NEW file must also fit the project's 500-file / 16 MB budget. Pass base_version only when replacing an existing file.", {
1307
1639
  path: z.string().min(1).describe("'logo.svg' (bare name → assets/logo.svg), 'assets/logo.svg', or 'styles/resources.json'."),
1308
1640
  content: z.string().describe("File text. Raster images as a data:<mime>;base64,<payload> string."),
1309
1641
  base_version: z.number().int().optional().describe("Version from a prior read; omit to force a replacement."),
@@ -1364,6 +1696,17 @@ export function buildServer(options = {}) {
1364
1696
  return fail(err);
1365
1697
  }
1366
1698
  });
1699
+ const deliverArg = z
1700
+ .object({
1701
+ to: z
1702
+ .enum(["inline", "message", "url"])
1703
+ .describe("'inline' (default, the image comes to you) | 'message' (post it to a channel) | 'url' (store it, get the URL)."),
1704
+ channel: z.number().int().optional().describe("Required by 'message': the channel id to post in (see list_channels)."),
1705
+ body: z.string().optional().describe("The message text when delivering to a channel."),
1706
+ reply_to: z.number().int().optional().describe("Post as a reply to this message id, in the same channel."),
1707
+ })
1708
+ .optional()
1709
+ .describe("Send the render to a PERSON instead of into your context (#525): use it whenever the human asked to see the design, and the picture costs you no tokens. The same rules as send_message apply (you must be able to post in that channel). `max_px`/`scale` still control the delivered file — pass max_px 0 for full resolution.");
1367
1710
  server.tool("design_screenshot", "Render a route in headless Chromium and return a PNG. You cannot see the UI otherwise — call this on a route BEFORE and AFTER your edits and self-critique the result. " +
1368
1711
  "Phone/tablet viewports render as a real mobile browser (touch, mobile user agent). " +
1369
1712
  "Size: a preset `viewport`, or a custom `width`+`height` (CSS px, 200–4000) with optional `dpr` (1–4). " +
@@ -1398,9 +1741,10 @@ export function buildServer(options = {}) {
1398
1741
  .optional()
1399
1742
  .describe("Longest side of the returned image (default 1568, what you can read; 0 = full resolution, e.g. to hand to a human)."),
1400
1743
  state: z.string().optional().describe("Overlay state to open on load, e.g. 'dialog:confirm-delete'."),
1744
+ deliver: deliverArg,
1401
1745
  ...designProjectArg,
1402
1746
  ...profileArg,
1403
- }, async ({ route, viewport, width, height, dpr, mobile, full_page, frame, theme, tokens, css, max_px, state, project, profile }) => {
1747
+ }, async ({ route, viewport, width, height, dpr, mobile, full_page, frame, theme, tokens, css, max_px, state, deliver, project, profile }) => {
1404
1748
  try {
1405
1749
  const picked = await clientFor(profile);
1406
1750
  if (!picked.ok)
@@ -1418,6 +1762,16 @@ export function buildServer(options = {}) {
1418
1762
  tokens,
1419
1763
  css,
1420
1764
  max_px,
1765
+ // Flattened: the route is a GET, so `deliver` travels as four query
1766
+ // parameters rather than one nested object.
1767
+ ...(deliver
1768
+ ? {
1769
+ deliver: deliver.to,
1770
+ ...(deliver.channel === undefined ? {} : { deliver_channel: deliver.channel }),
1771
+ ...(deliver.body === undefined ? {} : { deliver_body: deliver.body }),
1772
+ ...(deliver.reply_to === undefined ? {} : { deliver_reply_to: deliver.reply_to }),
1773
+ }
1774
+ : {}),
1421
1775
  });
1422
1776
  // `all` may be partial: an entry with an error and no picture becomes a
1423
1777
  // text line, not an image.
@@ -1428,7 +1782,9 @@ export function buildServer(options = {}) {
1428
1782
  : [];
1429
1783
  const images = entries.filter((e) => !!e.png_base64);
1430
1784
  const failed = entries.filter((e) => !e.png_base64);
1431
- if (!images.length) {
1785
+ // #525: a delivered render has no image BY DESIGN; that is not a failed
1786
+ // render, and it must not throw.
1787
+ if (!images.length && !shot.delivered) {
1432
1788
  throw new Error(failed.length
1433
1789
  ? `No theme could be rendered. ${failed.map((f) => `${f.theme}: ${f.error ?? "no image"}`).join("; ")}`
1434
1790
  : "Renderer returned no image.");
@@ -1452,11 +1808,14 @@ export function buildServer(options = {}) {
1452
1808
  : "";
1453
1809
  return {
1454
1810
  content: [
1455
- ...images.map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1811
+ ...images
1812
+ .filter((image) => image.png_base64)
1813
+ .map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1456
1814
  {
1457
1815
  type: "text",
1458
1816
  text: `Screenshot of ${shot.route} at ${shot.viewport}${size}${shade}${shot.full_page ? ", full page" : ""}${dress}. ` +
1459
- `Self-critique it against the tokens scale and your instruction before calling it done.${warnings}${failures}`,
1817
+ `Self-critique it against the tokens scale and your instruction before calling it done.${warnings}${failures}` +
1818
+ deliveredNote(shot.delivered),
1460
1819
  },
1461
1820
  ],
1462
1821
  };
@@ -1507,9 +1866,10 @@ export function buildServer(options = {}) {
1507
1866
  css: z.string().optional().describe("CSS every variant gets (before its own)."),
1508
1867
  include_apply: z.boolean().optional().describe("Return apply[label].patch per variant (default false)."),
1509
1868
  max_px: z.number().int().min(0).max(8000).optional().describe("Longest side of each image (default 1568; 0 = full size)."),
1869
+ deliver: deliverArg,
1510
1870
  ...designProjectArg,
1511
1871
  ...profileArg,
1512
- }, async ({ routes, variants, themes, viewport, width, height, checks, scale, tokens, css, include_apply, max_px, project, profile }) => {
1872
+ }, async ({ routes, variants, themes, viewport, width, height, checks, scale, tokens, css, include_apply, max_px, deliver, project, profile }) => {
1513
1873
  try {
1514
1874
  const picked = await clientFor(profile);
1515
1875
  if (!picked.ok)
@@ -1529,8 +1889,17 @@ export function buildServer(options = {}) {
1529
1889
  css,
1530
1890
  include_apply,
1531
1891
  max_px,
1892
+ ...(deliver
1893
+ ? {
1894
+ deliver: deliver.to,
1895
+ ...(deliver.channel === undefined ? {} : { deliver_channel: deliver.channel }),
1896
+ ...(deliver.body === undefined ? {} : { deliver_body: deliver.body }),
1897
+ ...(deliver.reply_to === undefined ? {} : { deliver_reply_to: deliver.reply_to }),
1898
+ }
1899
+ : {}),
1532
1900
  });
1533
- if (!out.images?.length)
1901
+ // #525: a delivered grid has no images BY DESIGN.
1902
+ if (!out.images?.length && !out.delivered)
1534
1903
  throw new Error("Renderer returned no image.");
1535
1904
  const lines = [
1536
1905
  `Compare grid: columns ${out.grid.columns.join(" | ")}; rows ${out.grid.rows.map((r) => `${r.label ?? r.route} (${r.theme})`).join(", ")}.`,
@@ -1553,9 +1922,16 @@ export function buildServer(options = {}) {
1553
1922
  if (out.warnings.length) {
1554
1923
  lines.push("", "WARNINGS — the picture differs from a real browser here:", ...out.warnings.map((w) => `- ${w}`));
1555
1924
  }
1925
+ const delivered = deliveredNote(out.delivered);
1926
+ if (delivered)
1927
+ lines.push(delivered);
1556
1928
  return {
1557
1929
  content: [
1558
- ...out.images.map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1930
+ // #525: a delivered render carries no base64 — never an image
1931
+ // block with `undefined` data.
1932
+ ...out.images
1933
+ .filter((image) => image.png_base64)
1934
+ .map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1559
1935
  { type: "text", text: lines.join("\n") },
1560
1936
  ],
1561
1937
  };
@@ -1605,6 +1981,221 @@ export function buildServer(options = {}) {
1605
1981
  return fail(err);
1606
1982
  }
1607
1983
  });
1984
+ // ---- #617: the project toolbox -------------------------------------------
1985
+ //
1986
+ // Progressive disclosure, enforced and not merely described: `toolbox_list`
1987
+ // returns names and descriptions and NEVER code, `toolbox_get` is the only
1988
+ // way code reaches the context, and installing is gated on having seen the
1989
+ // exact version this session (by fetching it, diffing to it, or writing it).
1990
+ // name -> versions this session has SEEN. Process-local, so a reconnect
1991
+ // starts clean and cannot install on an earlier session's reading.
1992
+ const toolboxSeen = new Map();
1993
+ const markSeen = (name, version) => {
1994
+ const set = toolboxSeen.get(name) ?? new Set();
1995
+ set.add(version);
1996
+ toolboxSeen.set(name, set);
1997
+ };
1998
+ server.tool("toolbox_list", "What this project's toolbox already knows: memories (project facts, gotchas) and scripts other agents built, tested and saved. Titles, descriptions, tags, status, version and run stats ONLY — no code, no bodies. Call this BEFORE building tooling for a project: the report script you are about to reinvent probably already exists.", {
1999
+ kind: z.enum(["memory", "script"]).optional().describe("Filter to one kind."),
2000
+ tag: z.string().optional().describe("Only entries carrying this tag."),
2001
+ query: z.string().optional().describe("Free text over name, title and description."),
2002
+ ...profileArg,
2003
+ }, async ({ kind, tag, query, profile }) => {
2004
+ try {
2005
+ const picked = await clientFor(profile);
2006
+ if (!picked.ok)
2007
+ return picked.refusal;
2008
+ return ok(await picked.client.toolboxList({ kind, tag, query }));
2009
+ }
2010
+ catch (err) {
2011
+ return fail(err);
2012
+ }
2013
+ });
2014
+ server.tool("toolbox_get", "Read ONE toolbox entry in full: a memory's text, or a script's code with its params, requires_env and usage notes. The ONLY tool that puts an entry's code in front of you — use it when you need the entry, never to browse. For a script, read the code before running it; then toolbox_install to put it where you can run it.", {
2015
+ name: z.string().min(1).describe("Entry name from toolbox_list or the connect-time index."),
2016
+ ...profileArg,
2017
+ }, async ({ name, profile }) => {
2018
+ try {
2019
+ const picked = await clientFor(profile);
2020
+ if (!picked.ok)
2021
+ return picked.refusal;
2022
+ const entry = (await picked.client.toolboxGet(name));
2023
+ if (entry?.name && typeof entry.version === "number")
2024
+ markSeen(entry.name, entry.version);
2025
+ return ok(entry);
2026
+ }
2027
+ catch (err) {
2028
+ return fail(err);
2029
+ }
2030
+ });
2031
+ server.tool("toolbox_save", "Save a memory or a SCRIPT into the project toolbox, so every agent on the project — including on your colleagues' machines — starts from what you learned. A memory for a non-obvious project fact or gotcha; a script once you have RUN it and it works, not before. NEVER put credentials, tokens or private keys in the code: they are refused outright, and the right shape is requires_env plus reading the values at run time. Hosts, emails and IPs are REPLACED on save and reported back — parameterise them instead. Updating an existing entry needs `base_version` (from toolbox_get); a stale one is a 409.", {
2032
+ name: z
2033
+ .string()
2034
+ .min(1)
2035
+ .describe("Unique slug for this project: lowercase letters, digits, `-`, `_`. The handle everything else uses."),
2036
+ kind: z.enum(["memory", "script"]).describe("memory = prose an agent reads; script = code an agent runs locally."),
2037
+ title: z.string().min(1).describe("Human title."),
2038
+ description: z
2039
+ .string()
2040
+ .min(1)
2041
+ .describe("ONE line, and it must say WHEN to use the entry — this is what the index shows, so it is the retrieval signal."),
2042
+ body_markdown: z.string().optional().describe("The memory itself, or a script's usage notes."),
2043
+ code: z.string().optional().describe("Scripts only: the full source."),
2044
+ language: z.string().optional().describe("Scripts only: bash, python, node, …"),
2045
+ entrypoint: z.string().optional().describe("Scripts only: the file to run (defaults from the name and language)."),
2046
+ params: z
2047
+ .array(z.object({ name: z.string(), description: z.string().optional(), required: z.boolean().optional() }))
2048
+ .optional()
2049
+ .describe("Scripts only: the arguments a caller passes."),
2050
+ requires_env: z
2051
+ .array(z.string())
2052
+ .optional()
2053
+ .describe("Scripts only: environment VARIABLE NAMES the script reads. Never values."),
2054
+ tags: z.array(z.string()).optional(),
2055
+ base_version: z.number().int().optional().describe("Required to update an existing entry: the version you are editing."),
2056
+ note: z.string().optional().describe("Why this version replaces the last — what a diff reader wants."),
2057
+ dry_run: z.boolean().optional().describe("Check the redaction report without saving, so you can fix the source first."),
2058
+ ...profileArg,
2059
+ }, async (input) => {
2060
+ try {
2061
+ const picked = await clientFor(input.profile);
2062
+ if (!picked.ok)
2063
+ return picked.refusal;
2064
+ const { name, profile: _profile, ...fields } = input;
2065
+ const saved = (await picked.client.toolboxSave(name, fields));
2066
+ if (!input.dry_run && saved?.entry?.name && typeof saved.entry.version === "number") {
2067
+ // The author has necessarily read what it wrote, so this version is
2068
+ // installable without a separate get.
2069
+ markSeen(saved.entry.name, saved.entry.version);
2070
+ }
2071
+ return ok(saved);
2072
+ }
2073
+ catch (err) {
2074
+ return fail(err);
2075
+ }
2076
+ });
2077
+ server.tool("toolbox_report_run", "Say how a toolbox script's run went, after you ran it. This is the feedback loop that makes a broken script visible as broken (and a fixed one as fixed) to the whole project — call it every time you run one.", {
2078
+ name: z.string().min(1),
2079
+ ok: z.boolean().describe("Did it do its job?"),
2080
+ note: z.string().optional().describe("What happened, briefly — especially when ok is false."),
2081
+ ...profileArg,
2082
+ }, async ({ name, ok: ranOk, note, profile }) => {
2083
+ try {
2084
+ const picked = await clientFor(profile);
2085
+ if (!picked.ok)
2086
+ return picked.refusal;
2087
+ return ok(await picked.client.toolboxReportRun(name, ranOk, note));
2088
+ }
2089
+ catch (err) {
2090
+ return fail(err);
2091
+ }
2092
+ });
2093
+ server.tool("toolbox_deprecate", "Retire a toolbox entry with a reason: a script a newer one replaces, a memory that stopped being true. Prefer this to leaving wrong knowledge in place — the reason is what the next agent reads.", {
2094
+ name: z.string().min(1),
2095
+ reason: z.string().min(1).describe("Why it should no longer be used."),
2096
+ ...profileArg,
2097
+ }, async ({ name, reason, profile }) => {
2098
+ try {
2099
+ const picked = await clientFor(profile);
2100
+ if (!picked.ok)
2101
+ return picked.refusal;
2102
+ return ok(await picked.client.toolboxDeprecate(name, reason));
2103
+ }
2104
+ catch (err) {
2105
+ return fail(err);
2106
+ }
2107
+ });
2108
+ server.tool("toolbox_diff", "Show ONLY what changed between two versions of a script, so re-vetting an update is quick reading instead of re-reading everything. Use it when the connect-time index says an installed script has a newer version.", {
2109
+ name: z.string().min(1),
2110
+ from: z.number().int().describe("The version you vetted (the index's 'installed vX')."),
2111
+ to: z.number().int().describe("The newer version."),
2112
+ ...profileArg,
2113
+ }, async ({ name, from, to, profile }) => {
2114
+ try {
2115
+ const picked = await clientFor(profile);
2116
+ if (!picked.ok)
2117
+ return picked.refusal;
2118
+ const [before, after] = await Promise.all([
2119
+ picked.client.toolboxGetVersion(name, from),
2120
+ picked.client.toolboxGetVersion(name, to),
2121
+ ]);
2122
+ const beforeCode = before.code ?? "";
2123
+ const afterCode = after.code ?? "";
2124
+ markSeen(name, to);
2125
+ return ok({
2126
+ name,
2127
+ from,
2128
+ to,
2129
+ identical: beforeCode === afterCode,
2130
+ diff: diffLines(beforeCode, afterCode),
2131
+ before,
2132
+ after,
2133
+ });
2134
+ }
2135
+ catch (err) {
2136
+ return fail(err);
2137
+ }
2138
+ });
2139
+ server.tool("toolbox_install", "Install a REVIEWED script into .taskflow/scripts/<name>/ in this checkout, so it runs directly from now on with no fetching and no re-reading. Requires `vetted_note`: your own sentence about what you checked ('read-only SSH, runs df and journalctl, prints to stdout, no deletes'). You must have seen THIS version already — via toolbox_get, toolbox_diff, or writing it yourself — because installing code nobody read is exactly what the gate prevents. The checkout keeps running its installed version; a newer server version is reported at connect but is NOT installed until you review the diff and install again.", {
2140
+ name: z.string().min(1),
2141
+ version: z.number().int().optional().describe("Defaults to the current version."),
2142
+ vetted_note: z.string().min(4).describe("What you checked in the code before installing it."),
2143
+ ...profileArg,
2144
+ }, async ({ name, version, vetted_note, profile }) => {
2145
+ try {
2146
+ const picked = await clientFor(profile);
2147
+ if (!picked.ok)
2148
+ return picked.refusal;
2149
+ const entry = (await picked.client.toolboxGet(name));
2150
+ if (entry?.kind !== "script") {
2151
+ return fail(new Error(`'${name}' is not a script — there is nothing to install. Read it with toolbox_get instead.`));
2152
+ }
2153
+ const target = version ?? entry.version;
2154
+ if (!toolboxSeen.get(name)?.has(target)) {
2155
+ return fail(new Error(`Read '${name}' v${target} first: toolbox_get, or toolbox_diff from the version you vetted. Installing a script you have not read is what the vetting gate prevents.`));
2156
+ }
2157
+ const source = target === entry.version
2158
+ ? entry
2159
+ : (await picked.client.toolboxGetVersion(name, target));
2160
+ const agentLabel = await agentLabelFor(picked.client);
2161
+ const install = await installScript({
2162
+ projectRoot: dirname(configPath),
2163
+ entry: { ...source, name, version: target },
2164
+ vettedBy: agentLabel,
2165
+ vettedNote: vetted_note,
2166
+ });
2167
+ // Record the vetting server-side so teammates can see who checked what.
2168
+ // Best-effort: a failed record must not undo an install already on disk.
2169
+ const vetted = await picked.client
2170
+ .toolboxVet(name, target, agentLabel, vetted_note)
2171
+ .then(() => true)
2172
+ .catch(() => false);
2173
+ return ok({
2174
+ ...install,
2175
+ vetted_recorded: vetted,
2176
+ note: `Installed. Run it as ${install.run_command}, then call toolbox_report_run with how it went.${install.missing_env.length
2177
+ ? ` This machine is missing ${install.missing_env.join(", ")} — ask your human to set them (values never go through TaskFlow).`
2178
+ : ""}`,
2179
+ });
2180
+ }
2181
+ catch (err) {
2182
+ return fail(err);
2183
+ }
2184
+ });
2185
+ server.tool("toolbox_uninstall", "Remove an installed script from this checkout — its folder and its manifest row. The server copy is untouched; this only cleans the local scripts directory.", {
2186
+ name: z.string().min(1),
2187
+ ...profileArg,
2188
+ }, async ({ name, profile }) => {
2189
+ try {
2190
+ const picked = await clientFor(profile);
2191
+ if (!picked.ok)
2192
+ return picked.refusal;
2193
+ return ok(await uninstallScript({ projectRoot: dirname(configPath), name }));
2194
+ }
2195
+ catch (err) {
2196
+ return fail(err);
2197
+ }
2198
+ });
1608
2199
  return server;
1609
2200
  }
1610
2201
  //# sourceMappingURL=server.js.map