@dalmasonto/taskflow-mcp 2.13.1 → 2.14.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
@@ -20,6 +20,7 @@ import { getMirrorStatus } from "./mirror.js";
20
20
  import { downloadAttachment } from "./attachment-download.js";
21
21
  import { detectTmuxPane } from "./tmux.js";
22
22
  import { AGENT_INSTRUCTIONS } from "./instructions.js";
23
+ import { diffLines, installScript, installedStatus, uninstallScript, } from "./toolbox.js";
23
24
  import { sessionIdentifier } from "./session-identifier.js";
24
25
  import { getConnectionStatus } from "./connect.js";
25
26
  import { selectProfile } from "./runtime.js";
@@ -119,10 +120,77 @@ export function collisionWarning(profileName, live) {
119
120
  `identity share one inbox and one read cursor, so messages meant for one will ` +
120
121
  `be marked read by the other. Tell your human before you continue.`);
121
122
  }
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
- */
123
+ /// The label a vetting record carries: the agent's own display name, so a
124
+ /// teammate reading "vetted by Build 3" knows who to ask.
125
+ async function agentLabelFor(client) {
126
+ try {
127
+ const identity = (await client.whoami());
128
+ return identity?.display_name ?? identity?.agent?.display_name ?? "an agent";
129
+ }
130
+ catch {
131
+ return "an agent";
132
+ }
133
+ }
134
+ async function toolboxIndexFor(client, projectRoot) {
135
+ try {
136
+ const listed = (await client.toolboxList());
137
+ const entries = (listed?.entries ?? []);
138
+ const scripts = entries.filter((entry) => entry.kind === "script");
139
+ const statuses = scripts.length
140
+ ? await installedStatus({
141
+ projectRoot,
142
+ serverScripts: scripts.map((script) => ({ name: script.name, version: script.version })),
143
+ })
144
+ : [];
145
+ const byName = new Map(statuses.map((status) => [status.name, status]));
146
+ return {
147
+ count: entries.length,
148
+ entries: entries.map((entry) => {
149
+ const status = byName.get(entry.name);
150
+ return {
151
+ name: entry.name,
152
+ kind: entry.kind,
153
+ description: entry.description,
154
+ status: entry.status,
155
+ version: entry.version,
156
+ install_state: status?.state ?? "n/a",
157
+ installed_version: status?.installed_version ?? null,
158
+ local_edit: status?.local_edit ?? false,
159
+ };
160
+ }),
161
+ };
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /** The readable half of the index: what an agent reads without calling anything
168
+ * else. Kept SHORT — entries are one line each, and code never appears. */
169
+ function renderToolboxIndex(index) {
170
+ if (!index || index.count === 0)
171
+ return null;
172
+ const lines = [
173
+ `Project toolbox — ${index.count} entr${index.count === 1 ? "y" : "ies"} already saved for this project:`,
174
+ ];
175
+ for (const entry of index.entries.slice(0, 24)) {
176
+ const marker = entry.kind === "memory"
177
+ ? "[memory]"
178
+ : entry.install_state === "current"
179
+ ? `[installed v${entry.installed_version}]`
180
+ : entry.install_state === "update-available"
181
+ ? `[installed v${entry.installed_version}, update available: v${entry.version}]`
182
+ : entry.install_state === "locally-edited"
183
+ ? `[installed v${entry.installed_version}, EDITED LOCALLY — save the fix with toolbox_save or reinstall]`
184
+ : "[script, not installed]";
185
+ const status = entry.status !== "approved" ? ` (${entry.status})` : "";
186
+ lines.push(`- ${entry.name} ${marker}${status} — ${entry.description}`);
187
+ }
188
+ if (index.entries.length > 24) {
189
+ lines.push(`…and ${index.entries.length - 24} more: call toolbox_list.`);
190
+ }
191
+ 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.");
192
+ return lines.join("\n");
193
+ }
126
194
  export function buildServer(options = {}) {
127
195
  const configPath = findConfigPath({
128
196
  configPath: options.configPath,
@@ -254,6 +322,12 @@ export function buildServer(options = {}) {
254
322
  return picked.refusal;
255
323
  const { client } = picked;
256
324
  const identity = await client.whoami();
325
+ // #617: the toolbox index at connect. Progressive disclosure is the
326
+ // whole point: names and one-line descriptions (never code), plus what
327
+ // is already installed locally and what has a newer version — so the
328
+ // agent knows what exists and what it can run WITHOUT loading any of
329
+ // it. A toolbox that is unreachable must not break whoami.
330
+ const toolbox = await toolboxIndexFor(client, dirname(configPath));
257
331
  // The mirror's health rides along with identity because a dead mirror
258
332
  // is otherwise invisible: it only ever wrote one line to stderr, which
259
333
  // nobody reads, so "the dashboard terminal is stale" could only be
@@ -262,12 +336,16 @@ export function buildServer(options = {}) {
262
336
  ...identity,
263
337
  connection: getConnectionStatus(),
264
338
  mirror: getMirrorStatus(),
339
+ ...(toolbox ? { toolbox } : {}),
265
340
  });
266
341
  // #615/#616: the JSON escapes every newline, so long markdown is
267
342
  // unreadable there. Show it again as plain text when there is any.
268
343
  const readable = renderInstructions(identity);
269
344
  if (readable)
270
345
  result.content.push({ type: "text", text: readable });
346
+ const toolboxText = renderToolboxIndex(toolbox);
347
+ if (toolboxText)
348
+ result.content.push({ type: "text", text: toolboxText });
271
349
  return result;
272
350
  }
273
351
  catch (err) {
@@ -375,12 +453,16 @@ export function buildServer(options = {}) {
375
453
  .describe("Task priority (default: normal)."),
376
454
  notes: z.string().optional().describe("Markdown notes."),
377
455
  claim: z.boolean().optional().describe("If true, assign the new task to this agent."),
456
+ repo: z
457
+ .string()
458
+ .optional()
459
+ .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
460
  files: z
379
461
  .array(z.string())
380
462
  .optional()
381
463
  .describe("Paths to attach to the new task, relative to the project root (or absolute, inside it). Max 25MB each."),
382
464
  ...profileArg,
383
- }, async ({ title, description, notes, priority, claim, files, profile }) => {
465
+ }, async ({ title, description, notes, priority, claim, files, repo, profile }) => {
384
466
  try {
385
467
  const picked = await clientFor(profile);
386
468
  if (!picked.ok)
@@ -392,6 +474,7 @@ export function buildServer(options = {}) {
392
474
  notes_markdown: notes,
393
475
  priority,
394
476
  claim,
477
+ repo,
395
478
  }));
396
479
  if (!files?.length)
397
480
  return ok(task);
@@ -438,8 +521,13 @@ export function buildServer(options = {}) {
438
521
  .array(z.string())
439
522
  .optional()
440
523
  .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."),
524
+ repo: z
525
+ .string()
526
+ .nullable()
527
+ .optional()
528
+ .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
529
  ...profileArg,
442
- }, async ({ task, title, description, notes, priority, files, profile }) => {
530
+ }, async ({ task, title, description, notes, priority, files, repo, profile }) => {
443
531
  try {
444
532
  const picked = await clientFor(profile);
445
533
  if (!picked.ok)
@@ -450,9 +538,10 @@ export function buildServer(options = {}) {
450
538
  const hasFields = title !== undefined ||
451
539
  description !== undefined ||
452
540
  notes !== undefined ||
453
- priority !== undefined;
541
+ priority !== undefined ||
542
+ repo !== undefined;
454
543
  if (!hasFields && !files?.length) {
455
- return fail(new Error("Nothing to update: pass at least one of title, description, notes, priority or files."));
544
+ return fail(new Error("Nothing to update: pass at least one of title, description, notes, priority, repo or files."));
456
545
  }
457
546
  let updated = undefined;
458
547
  if (hasFields) {
@@ -461,6 +550,7 @@ export function buildServer(options = {}) {
461
550
  description_markdown: description,
462
551
  notes_markdown: notes,
463
552
  priority,
553
+ repo,
464
554
  });
465
555
  }
466
556
  if (!files?.length)
@@ -568,8 +658,37 @@ export function buildServer(options = {}) {
568
658
  .boolean()
569
659
  .optional()
570
660
  .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."),
661
+ questions: z
662
+ .object({
663
+ title: z.string().optional().describe("Heading for the whole form."),
664
+ questions: z
665
+ .array(z.object({
666
+ key: z
667
+ .string()
668
+ .describe("Stable id for the answer, unique in this set (letters, digits, - _ :), max 40."),
669
+ label: z.string().describe("The question as the person reads it."),
670
+ type: z.enum(["radio", "select", "text", "date"]),
671
+ options: z
672
+ .array(z.object({
673
+ value: z.string().describe("What comes back as the answer."),
674
+ label: z.string(),
675
+ description: z.string().optional().describe("A supporting line under the option."),
676
+ }))
677
+ .optional()
678
+ .describe("radio/select: at least 2 required."),
679
+ allow_other: z.boolean().optional().describe("radio/select: offer an 'Other…' text input."),
680
+ required: z.boolean().optional().describe("The person cannot submit until it is answered."),
681
+ placeholder: z.string().optional().describe("text/date only."),
682
+ multiline: z.boolean().optional().describe("text only: a taller box for prose answers."),
683
+ }))
684
+ .describe("1-8 questions."),
685
+ submit_label: z.string().optional(),
686
+ cancel_label: z.string().optional(),
687
+ })
688
+ .optional()
689
+ .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
690
  ...profileArg,
572
- }, async ({ channel, body, reply_to, priority, files, is_design, profile }) => {
691
+ }, async ({ channel, body, reply_to, priority, files, is_design, questions, profile }) => {
573
692
  try {
574
693
  const picked = await clientFor(profile);
575
694
  if (!picked.ok)
@@ -584,6 +703,7 @@ export function buildServer(options = {}) {
584
703
  priority,
585
704
  is_design,
586
705
  attachments,
706
+ questions,
587
707
  ...(reply_to === undefined ? {} : { reply_to }),
588
708
  }));
589
709
  }
@@ -899,6 +1019,111 @@ export function buildServer(options = {}) {
899
1019
  return fail(err);
900
1020
  }
901
1021
  });
1022
+ 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.", {
1023
+ from: z.string().min(1).describe("The route as it is now, e.g. '/ai-coach'."),
1024
+ to: z.string().min(1).describe("The route it should become, e.g. '/pulse'. Must not already serve a page."),
1025
+ ...designProjectArg,
1026
+ ...profileArg,
1027
+ }, async ({ from, to, project, profile }) => {
1028
+ try {
1029
+ const picked = await clientFor(profile);
1030
+ if (!picked.ok)
1031
+ return picked.refusal;
1032
+ const { client } = picked;
1033
+ return ok(await client.designRenameRoute(await resolveDesignProject(client, project), from, to));
1034
+ }
1035
+ catch (err) {
1036
+ return fail(err);
1037
+ }
1038
+ });
1039
+ 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.", {
1040
+ path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
1041
+ ...designProjectArg,
1042
+ ...profileArg,
1043
+ }, async ({ path, project, profile }) => {
1044
+ try {
1045
+ const picked = await clientFor(profile);
1046
+ if (!picked.ok)
1047
+ return picked.refusal;
1048
+ const { client } = picked;
1049
+ return ok(await client.designVersions(await resolveDesignProject(client, project), path));
1050
+ }
1051
+ catch (err) {
1052
+ return fail(err);
1053
+ }
1054
+ });
1055
+ 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.", {
1056
+ path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
1057
+ version: z.number().int().describe("The file version from design_list_versions."),
1058
+ ...designProjectArg,
1059
+ ...profileArg,
1060
+ }, async ({ path, version, project, profile }) => {
1061
+ try {
1062
+ const picked = await clientFor(profile);
1063
+ if (!picked.ok)
1064
+ return picked.refusal;
1065
+ const { client } = picked;
1066
+ return ok(await client.designVersion(await resolveDesignProject(client, project), path, version));
1067
+ }
1068
+ catch (err) {
1069
+ return fail(err);
1070
+ }
1071
+ });
1072
+ 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`.", {
1073
+ path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
1074
+ version: z.number().int().describe("The file version to bring back."),
1075
+ ...designProjectArg,
1076
+ ...profileArg,
1077
+ }, async ({ path, version, project, profile }) => {
1078
+ try {
1079
+ const picked = await clientFor(profile);
1080
+ if (!picked.ok)
1081
+ return picked.refusal;
1082
+ const { client } = picked;
1083
+ return ok(await client.designRestoreVersion(await resolveDesignProject(client, project), path, version));
1084
+ }
1085
+ catch (err) {
1086
+ return fail(err);
1087
+ }
1088
+ });
1089
+ 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. " +
1090
+ "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. " +
1091
+ "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. " +
1092
+ "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.", {
1093
+ query: z.string().min(1).describe("The term to find, e.g. 'AI coach'."),
1094
+ regex: z.boolean().optional().describe("Treat the query as a Rust regex (default false)."),
1095
+ case_sensitive: z.boolean().optional().describe("Default false: matching ignores case."),
1096
+ whole_word: z.boolean().optional().describe("Default TRUE: 'coach' does not match 'coaching'. Pass false for substring matching."),
1097
+ scope: z
1098
+ .string()
1099
+ .optional()
1100
+ .describe("Comma-separated: text (default), attributes, code, page_names, groups, flow_labels."),
1101
+ in: z.enum(["pages", "components", "both"]).optional().describe("Where to search (default both)."),
1102
+ cursor: z.string().optional().describe("next_cursor from the previous call; files after it are returned."),
1103
+ limit: z.number().int().min(1).max(100).optional().describe("Files per page (default 25, max 100)."),
1104
+ ...designProjectArg,
1105
+ ...profileArg,
1106
+ }, async ({ query, regex, case_sensitive, whole_word, scope, in: where, cursor, limit, project, profile }) => {
1107
+ try {
1108
+ const picked = await clientFor(profile);
1109
+ if (!picked.ok)
1110
+ return picked.refusal;
1111
+ const { client } = picked;
1112
+ return ok(await client.designSearch(await resolveDesignProject(client, project), {
1113
+ query,
1114
+ regex,
1115
+ caseSensitive: case_sensitive,
1116
+ wholeWord: whole_word,
1117
+ scope,
1118
+ in: where,
1119
+ cursor,
1120
+ limit,
1121
+ }));
1122
+ }
1123
+ catch (err) {
1124
+ return fail(err);
1125
+ }
1126
+ });
902
1127
  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
1128
  // The arrangement is one document per project, so there is nothing to
904
1129
  // select: a route or a group id would be an argument this read has no use
@@ -1131,6 +1356,24 @@ export function buildServer(options = {}) {
1131
1356
  // tools make, in the route's own wire shape (plus #508's update_link and
1132
1357
  // place_page, which exist only here).
1133
1358
  const coord = z.number().finite();
1359
+ /** #525: the text a delivered render contributes — where it went, and the
1360
+ * reminder that the pixels are NOT in this reply. */
1361
+ const deliveredNote = (delivered) => {
1362
+ if (!delivered || typeof delivered !== "object")
1363
+ return "";
1364
+ const record = delivered;
1365
+ if (record.to === "message") {
1366
+ const count = Array.isArray(record.attachments) ? record.attachments.length : 0;
1367
+ 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.`;
1368
+ }
1369
+ if (record.to === "url") {
1370
+ const urls = Array.isArray(record.images)
1371
+ ? record.images.map((image) => image.url).filter(Boolean).join(", ")
1372
+ : "";
1373
+ return `\n\nDELIVERED as file(s): ${urls}. The image is NOT in this reply.`;
1374
+ }
1375
+ return "";
1376
+ };
1134
1377
  const layoutOpSchema = z.union([
1135
1378
  z.object({ create_group: z.object({ name: z.string() }).strict() }).strict(),
1136
1379
  z.object({ update_group: z.object({ group_id: z.string(), name: z.string() }).strict() }).strict(),
@@ -1176,7 +1419,7 @@ export function buildServer(options = {}) {
1176
1419
  .object({ place_page: z.object({ route: z.string().min(1), x: coord, y: coord }).strict() })
1177
1420
  .strict(),
1178
1421
  ]);
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. " +
1422
+ 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
1423
  baseVersionNote, {
1181
1424
  ...designProjectArg,
1182
1425
  ops: z.array(layoutOpSchema).min(1).max(100).describe("The edits, applied in order as one all-or-nothing write."),
@@ -1303,7 +1546,7 @@ export function buildServer(options = {}) {
1303
1546
  return fail(err);
1304
1547
  }
1305
1548
  });
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.", {
1549
+ 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
1550
  path: z.string().min(1).describe("'logo.svg' (bare name → assets/logo.svg), 'assets/logo.svg', or 'styles/resources.json'."),
1308
1551
  content: z.string().describe("File text. Raster images as a data:<mime>;base64,<payload> string."),
1309
1552
  base_version: z.number().int().optional().describe("Version from a prior read; omit to force a replacement."),
@@ -1364,6 +1607,17 @@ export function buildServer(options = {}) {
1364
1607
  return fail(err);
1365
1608
  }
1366
1609
  });
1610
+ const deliverArg = z
1611
+ .object({
1612
+ to: z
1613
+ .enum(["inline", "message", "url"])
1614
+ .describe("'inline' (default, the image comes to you) | 'message' (post it to a channel) | 'url' (store it, get the URL)."),
1615
+ channel: z.number().int().optional().describe("Required by 'message': the channel id to post in (see list_channels)."),
1616
+ body: z.string().optional().describe("The message text when delivering to a channel."),
1617
+ reply_to: z.number().int().optional().describe("Post as a reply to this message id, in the same channel."),
1618
+ })
1619
+ .optional()
1620
+ .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
1621
  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
1622
  "Phone/tablet viewports render as a real mobile browser (touch, mobile user agent). " +
1369
1623
  "Size: a preset `viewport`, or a custom `width`+`height` (CSS px, 200–4000) with optional `dpr` (1–4). " +
@@ -1398,9 +1652,10 @@ export function buildServer(options = {}) {
1398
1652
  .optional()
1399
1653
  .describe("Longest side of the returned image (default 1568, what you can read; 0 = full resolution, e.g. to hand to a human)."),
1400
1654
  state: z.string().optional().describe("Overlay state to open on load, e.g. 'dialog:confirm-delete'."),
1655
+ deliver: deliverArg,
1401
1656
  ...designProjectArg,
1402
1657
  ...profileArg,
1403
- }, async ({ route, viewport, width, height, dpr, mobile, full_page, frame, theme, tokens, css, max_px, state, project, profile }) => {
1658
+ }, async ({ route, viewport, width, height, dpr, mobile, full_page, frame, theme, tokens, css, max_px, state, deliver, project, profile }) => {
1404
1659
  try {
1405
1660
  const picked = await clientFor(profile);
1406
1661
  if (!picked.ok)
@@ -1418,6 +1673,16 @@ export function buildServer(options = {}) {
1418
1673
  tokens,
1419
1674
  css,
1420
1675
  max_px,
1676
+ // Flattened: the route is a GET, so `deliver` travels as four query
1677
+ // parameters rather than one nested object.
1678
+ ...(deliver
1679
+ ? {
1680
+ deliver: deliver.to,
1681
+ ...(deliver.channel === undefined ? {} : { deliver_channel: deliver.channel }),
1682
+ ...(deliver.body === undefined ? {} : { deliver_body: deliver.body }),
1683
+ ...(deliver.reply_to === undefined ? {} : { deliver_reply_to: deliver.reply_to }),
1684
+ }
1685
+ : {}),
1421
1686
  });
1422
1687
  // `all` may be partial: an entry with an error and no picture becomes a
1423
1688
  // text line, not an image.
@@ -1428,7 +1693,9 @@ export function buildServer(options = {}) {
1428
1693
  : [];
1429
1694
  const images = entries.filter((e) => !!e.png_base64);
1430
1695
  const failed = entries.filter((e) => !e.png_base64);
1431
- if (!images.length) {
1696
+ // #525: a delivered render has no image BY DESIGN; that is not a failed
1697
+ // render, and it must not throw.
1698
+ if (!images.length && !shot.delivered) {
1432
1699
  throw new Error(failed.length
1433
1700
  ? `No theme could be rendered. ${failed.map((f) => `${f.theme}: ${f.error ?? "no image"}`).join("; ")}`
1434
1701
  : "Renderer returned no image.");
@@ -1452,11 +1719,14 @@ export function buildServer(options = {}) {
1452
1719
  : "";
1453
1720
  return {
1454
1721
  content: [
1455
- ...images.map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1722
+ ...images
1723
+ .filter((image) => image.png_base64)
1724
+ .map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1456
1725
  {
1457
1726
  type: "text",
1458
1727
  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}`,
1728
+ `Self-critique it against the tokens scale and your instruction before calling it done.${warnings}${failures}` +
1729
+ deliveredNote(shot.delivered),
1460
1730
  },
1461
1731
  ],
1462
1732
  };
@@ -1507,9 +1777,10 @@ export function buildServer(options = {}) {
1507
1777
  css: z.string().optional().describe("CSS every variant gets (before its own)."),
1508
1778
  include_apply: z.boolean().optional().describe("Return apply[label].patch per variant (default false)."),
1509
1779
  max_px: z.number().int().min(0).max(8000).optional().describe("Longest side of each image (default 1568; 0 = full size)."),
1780
+ deliver: deliverArg,
1510
1781
  ...designProjectArg,
1511
1782
  ...profileArg,
1512
- }, async ({ routes, variants, themes, viewport, width, height, checks, scale, tokens, css, include_apply, max_px, project, profile }) => {
1783
+ }, async ({ routes, variants, themes, viewport, width, height, checks, scale, tokens, css, include_apply, max_px, deliver, project, profile }) => {
1513
1784
  try {
1514
1785
  const picked = await clientFor(profile);
1515
1786
  if (!picked.ok)
@@ -1529,8 +1800,17 @@ export function buildServer(options = {}) {
1529
1800
  css,
1530
1801
  include_apply,
1531
1802
  max_px,
1803
+ ...(deliver
1804
+ ? {
1805
+ deliver: deliver.to,
1806
+ ...(deliver.channel === undefined ? {} : { deliver_channel: deliver.channel }),
1807
+ ...(deliver.body === undefined ? {} : { deliver_body: deliver.body }),
1808
+ ...(deliver.reply_to === undefined ? {} : { deliver_reply_to: deliver.reply_to }),
1809
+ }
1810
+ : {}),
1532
1811
  });
1533
- if (!out.images?.length)
1812
+ // #525: a delivered grid has no images BY DESIGN.
1813
+ if (!out.images?.length && !out.delivered)
1534
1814
  throw new Error("Renderer returned no image.");
1535
1815
  const lines = [
1536
1816
  `Compare grid: columns ${out.grid.columns.join(" | ")}; rows ${out.grid.rows.map((r) => `${r.label ?? r.route} (${r.theme})`).join(", ")}.`,
@@ -1553,9 +1833,16 @@ export function buildServer(options = {}) {
1553
1833
  if (out.warnings.length) {
1554
1834
  lines.push("", "WARNINGS — the picture differs from a real browser here:", ...out.warnings.map((w) => `- ${w}`));
1555
1835
  }
1836
+ const delivered = deliveredNote(out.delivered);
1837
+ if (delivered)
1838
+ lines.push(delivered);
1556
1839
  return {
1557
1840
  content: [
1558
- ...out.images.map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1841
+ // #525: a delivered render carries no base64 — never an image
1842
+ // block with `undefined` data.
1843
+ ...out.images
1844
+ .filter((image) => image.png_base64)
1845
+ .map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
1559
1846
  { type: "text", text: lines.join("\n") },
1560
1847
  ],
1561
1848
  };
@@ -1605,6 +1892,221 @@ export function buildServer(options = {}) {
1605
1892
  return fail(err);
1606
1893
  }
1607
1894
  });
1895
+ // ---- #617: the project toolbox -------------------------------------------
1896
+ //
1897
+ // Progressive disclosure, enforced and not merely described: `toolbox_list`
1898
+ // returns names and descriptions and NEVER code, `toolbox_get` is the only
1899
+ // way code reaches the context, and installing is gated on having seen the
1900
+ // exact version this session (by fetching it, diffing to it, or writing it).
1901
+ // name -> versions this session has SEEN. Process-local, so a reconnect
1902
+ // starts clean and cannot install on an earlier session's reading.
1903
+ const toolboxSeen = new Map();
1904
+ const markSeen = (name, version) => {
1905
+ const set = toolboxSeen.get(name) ?? new Set();
1906
+ set.add(version);
1907
+ toolboxSeen.set(name, set);
1908
+ };
1909
+ 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.", {
1910
+ kind: z.enum(["memory", "script"]).optional().describe("Filter to one kind."),
1911
+ tag: z.string().optional().describe("Only entries carrying this tag."),
1912
+ query: z.string().optional().describe("Free text over name, title and description."),
1913
+ ...profileArg,
1914
+ }, async ({ kind, tag, query, profile }) => {
1915
+ try {
1916
+ const picked = await clientFor(profile);
1917
+ if (!picked.ok)
1918
+ return picked.refusal;
1919
+ return ok(await picked.client.toolboxList({ kind, tag, query }));
1920
+ }
1921
+ catch (err) {
1922
+ return fail(err);
1923
+ }
1924
+ });
1925
+ 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.", {
1926
+ name: z.string().min(1).describe("Entry name from toolbox_list or the connect-time index."),
1927
+ ...profileArg,
1928
+ }, async ({ name, profile }) => {
1929
+ try {
1930
+ const picked = await clientFor(profile);
1931
+ if (!picked.ok)
1932
+ return picked.refusal;
1933
+ const entry = (await picked.client.toolboxGet(name));
1934
+ if (entry?.name && typeof entry.version === "number")
1935
+ markSeen(entry.name, entry.version);
1936
+ return ok(entry);
1937
+ }
1938
+ catch (err) {
1939
+ return fail(err);
1940
+ }
1941
+ });
1942
+ 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.", {
1943
+ name: z
1944
+ .string()
1945
+ .min(1)
1946
+ .describe("Unique slug for this project: lowercase letters, digits, `-`, `_`. The handle everything else uses."),
1947
+ kind: z.enum(["memory", "script"]).describe("memory = prose an agent reads; script = code an agent runs locally."),
1948
+ title: z.string().min(1).describe("Human title."),
1949
+ description: z
1950
+ .string()
1951
+ .min(1)
1952
+ .describe("ONE line, and it must say WHEN to use the entry — this is what the index shows, so it is the retrieval signal."),
1953
+ body_markdown: z.string().optional().describe("The memory itself, or a script's usage notes."),
1954
+ code: z.string().optional().describe("Scripts only: the full source."),
1955
+ language: z.string().optional().describe("Scripts only: bash, python, node, …"),
1956
+ entrypoint: z.string().optional().describe("Scripts only: the file to run (defaults from the name and language)."),
1957
+ params: z
1958
+ .array(z.object({ name: z.string(), description: z.string().optional(), required: z.boolean().optional() }))
1959
+ .optional()
1960
+ .describe("Scripts only: the arguments a caller passes."),
1961
+ requires_env: z
1962
+ .array(z.string())
1963
+ .optional()
1964
+ .describe("Scripts only: environment VARIABLE NAMES the script reads. Never values."),
1965
+ tags: z.array(z.string()).optional(),
1966
+ base_version: z.number().int().optional().describe("Required to update an existing entry: the version you are editing."),
1967
+ note: z.string().optional().describe("Why this version replaces the last — what a diff reader wants."),
1968
+ dry_run: z.boolean().optional().describe("Check the redaction report without saving, so you can fix the source first."),
1969
+ ...profileArg,
1970
+ }, async (input) => {
1971
+ try {
1972
+ const picked = await clientFor(input.profile);
1973
+ if (!picked.ok)
1974
+ return picked.refusal;
1975
+ const { name, profile: _profile, ...fields } = input;
1976
+ const saved = (await picked.client.toolboxSave(name, fields));
1977
+ if (!input.dry_run && saved?.entry?.name && typeof saved.entry.version === "number") {
1978
+ // The author has necessarily read what it wrote, so this version is
1979
+ // installable without a separate get.
1980
+ markSeen(saved.entry.name, saved.entry.version);
1981
+ }
1982
+ return ok(saved);
1983
+ }
1984
+ catch (err) {
1985
+ return fail(err);
1986
+ }
1987
+ });
1988
+ 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.", {
1989
+ name: z.string().min(1),
1990
+ ok: z.boolean().describe("Did it do its job?"),
1991
+ note: z.string().optional().describe("What happened, briefly — especially when ok is false."),
1992
+ ...profileArg,
1993
+ }, async ({ name, ok: ranOk, note, profile }) => {
1994
+ try {
1995
+ const picked = await clientFor(profile);
1996
+ if (!picked.ok)
1997
+ return picked.refusal;
1998
+ return ok(await picked.client.toolboxReportRun(name, ranOk, note));
1999
+ }
2000
+ catch (err) {
2001
+ return fail(err);
2002
+ }
2003
+ });
2004
+ 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.", {
2005
+ name: z.string().min(1),
2006
+ reason: z.string().min(1).describe("Why it should no longer be used."),
2007
+ ...profileArg,
2008
+ }, async ({ name, reason, profile }) => {
2009
+ try {
2010
+ const picked = await clientFor(profile);
2011
+ if (!picked.ok)
2012
+ return picked.refusal;
2013
+ return ok(await picked.client.toolboxDeprecate(name, reason));
2014
+ }
2015
+ catch (err) {
2016
+ return fail(err);
2017
+ }
2018
+ });
2019
+ 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.", {
2020
+ name: z.string().min(1),
2021
+ from: z.number().int().describe("The version you vetted (the index's 'installed vX')."),
2022
+ to: z.number().int().describe("The newer version."),
2023
+ ...profileArg,
2024
+ }, async ({ name, from, to, profile }) => {
2025
+ try {
2026
+ const picked = await clientFor(profile);
2027
+ if (!picked.ok)
2028
+ return picked.refusal;
2029
+ const [before, after] = await Promise.all([
2030
+ picked.client.toolboxGetVersion(name, from),
2031
+ picked.client.toolboxGetVersion(name, to),
2032
+ ]);
2033
+ const beforeCode = before.code ?? "";
2034
+ const afterCode = after.code ?? "";
2035
+ markSeen(name, to);
2036
+ return ok({
2037
+ name,
2038
+ from,
2039
+ to,
2040
+ identical: beforeCode === afterCode,
2041
+ diff: diffLines(beforeCode, afterCode),
2042
+ before,
2043
+ after,
2044
+ });
2045
+ }
2046
+ catch (err) {
2047
+ return fail(err);
2048
+ }
2049
+ });
2050
+ 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.", {
2051
+ name: z.string().min(1),
2052
+ version: z.number().int().optional().describe("Defaults to the current version."),
2053
+ vetted_note: z.string().min(4).describe("What you checked in the code before installing it."),
2054
+ ...profileArg,
2055
+ }, async ({ name, version, vetted_note, profile }) => {
2056
+ try {
2057
+ const picked = await clientFor(profile);
2058
+ if (!picked.ok)
2059
+ return picked.refusal;
2060
+ const entry = (await picked.client.toolboxGet(name));
2061
+ if (entry?.kind !== "script") {
2062
+ return fail(new Error(`'${name}' is not a script — there is nothing to install. Read it with toolbox_get instead.`));
2063
+ }
2064
+ const target = version ?? entry.version;
2065
+ if (!toolboxSeen.get(name)?.has(target)) {
2066
+ 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.`));
2067
+ }
2068
+ const source = target === entry.version
2069
+ ? entry
2070
+ : (await picked.client.toolboxGetVersion(name, target));
2071
+ const agentLabel = await agentLabelFor(picked.client);
2072
+ const install = await installScript({
2073
+ projectRoot: dirname(configPath),
2074
+ entry: { ...source, name, version: target },
2075
+ vettedBy: agentLabel,
2076
+ vettedNote: vetted_note,
2077
+ });
2078
+ // Record the vetting server-side so teammates can see who checked what.
2079
+ // Best-effort: a failed record must not undo an install already on disk.
2080
+ const vetted = await picked.client
2081
+ .toolboxVet(name, target, agentLabel, vetted_note)
2082
+ .then(() => true)
2083
+ .catch(() => false);
2084
+ return ok({
2085
+ ...install,
2086
+ vetted_recorded: vetted,
2087
+ note: `Installed. Run it as ${install.run_command}, then call toolbox_report_run with how it went.${install.missing_env.length
2088
+ ? ` This machine is missing ${install.missing_env.join(", ")} — ask your human to set them (values never go through TaskFlow).`
2089
+ : ""}`,
2090
+ });
2091
+ }
2092
+ catch (err) {
2093
+ return fail(err);
2094
+ }
2095
+ });
2096
+ 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.", {
2097
+ name: z.string().min(1),
2098
+ ...profileArg,
2099
+ }, async ({ name, profile }) => {
2100
+ try {
2101
+ const picked = await clientFor(profile);
2102
+ if (!picked.ok)
2103
+ return picked.refusal;
2104
+ return ok(await uninstallScript({ projectRoot: dirname(configPath), name }));
2105
+ }
2106
+ catch (err) {
2107
+ return fail(err);
2108
+ }
2109
+ });
1608
2110
  return server;
1609
2111
  }
1610
2112
  //# sourceMappingURL=server.js.map