@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/agent-instructions.d.ts +9 -0
- package/dist/agent-instructions.js +15 -0
- package/dist/agent-instructions.js.map +1 -1
- package/dist/agents-manage.d.ts +17 -0
- package/dist/agents-manage.js +17 -0
- package/dist/agents-manage.js.map +1 -0
- package/dist/client.d.ts +158 -2
- package/dist/client.js +147 -2
- package/dist/client.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +50 -1
- package/dist/instructions.js.map +1 -1
- package/dist/server.d.ts +13 -0
- package/dist/server.js +612 -21
- package/dist/server.js.map +1 -1
- package/dist/toolbox.d.ts +128 -0
- package/dist/toolbox.js +249 -0
- package/dist/toolbox.js.map +1 -0
- package/package.json +1 -1
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|