toolroll 0.9.20 → 0.9.22
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/CHANGELOG.md +21 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -21
- package/dist/browser-workspace.d.ts +41 -1
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +57 -6
- package/dist/chat-delivery-state.d.ts +15 -0
- package/dist/chat-delivery-state.js +27 -2
- package/dist/chat-delivery.js +24 -4
- package/dist/chat-flow.d.ts +26 -3
- package/dist/chat-flow.js +217 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +2 -2
- package/dist/evidence.d.ts +13 -1
- package/dist/evidence.js +10 -1
- package/dist/flow-engine.d.ts +7 -0
- package/dist/flow-engine.js +71 -27
- package/dist/flow-gallery-ui.d.ts +1 -1
- package/dist/flow-gallery-ui.js +8 -7
- package/dist/flow-gallery.d.ts +8 -0
- package/dist/flow-gallery.js +37 -3
- package/dist/flow-send.d.ts +80 -0
- package/dist/flow-send.js +249 -0
- package/dist/flow-share.js +7 -3
- package/dist/flow-steps.js +1 -1
- package/dist/flows-cli.js +11 -3
- package/dist/flows-ui.js +20 -4
- package/dist/flows.d.ts +27 -1
- package/dist/flows.js +104 -18
- package/dist/mate-tools.js +33 -10
- package/dist/operate.js +41 -0
- package/dist/project-delete.js +4 -0
- package/dist/project-demo.d.ts +12 -0
- package/dist/project-demo.js +71 -0
- package/dist/provider.d.ts +6 -0
- package/dist/provider.js +4 -1
- package/dist/report-summary.d.ts +9 -0
- package/dist/report-summary.js +9 -0
- package/dist/result-shots.d.ts +2 -0
- package/dist/result-shots.js +24 -5
- package/dist/scout-browser.d.ts +48 -0
- package/dist/scout-browser.js +228 -0
- package/dist/scout-net.d.ts +22 -0
- package/dist/scout-net.js +214 -0
- package/dist/scout-report.d.ts +67 -0
- package/dist/scout-report.js +108 -1
- package/dist/scout.d.ts +48 -0
- package/dist/scout.js +264 -17
- package/dist/serve.js +43 -5
- package/dist/slack-chat.js +3 -3
- package/dist/store.d.ts +53 -0
- package/dist/store.js +95 -4
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-flow.d.ts +16 -2
- package/dist/telegram-flow.js +58 -0
- package/dist/telegram.js +46 -10
- package/package.json +1 -1
package/dist/operate.js
CHANGED
|
@@ -180,6 +180,7 @@ import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODEL
|
|
|
180
180
|
import { resolvePhaseAgent, resolveScopeProfile, resolveScopeChain, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
|
|
181
181
|
import { isRiskLevel, legOf, projectRoute, riskConsequence, routeDigestOf, routeWords, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
|
|
182
182
|
import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
|
|
183
|
+
import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
|
|
183
184
|
import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
|
|
184
185
|
import { clearWebhook, effectivePrimary, isMessagingChannel, loadConsoleUrl, loadPrimary, loadWebhookTargets, phoneOrigin, saveConsoleUrl, savePrimary, saveWebhook, webhookPass, SLACK_ENV, DISCORD_ENV } from "./webhooks.js";
|
|
185
186
|
import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
@@ -233,6 +234,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
233
234
|
toolroll project concurrency [<n>] --repo <p>
|
|
234
235
|
how many of a project's tasks build at once (default 2, never past the
|
|
235
236
|
worker's capacity); an approver changes it with <n>
|
|
237
|
+
toolroll project demo [<url>|off] --repo <p>
|
|
238
|
+
the project's own demo or dev server, the one non-public address a
|
|
239
|
+
report's scout may screenshot
|
|
236
240
|
toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
|
|
237
241
|
toolroll storage where the disk goes: database, build checkouts, releases, evidence, leftover test temp folders
|
|
238
242
|
toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay) and test temp folders older than a day; --yes removes them
|
|
@@ -971,6 +975,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
971
975
|
return projectDeleteCommand(positional, flags, context);
|
|
972
976
|
if (positional[0] === "concurrency")
|
|
973
977
|
return projectConcurrencyCommand(positional, flags, context);
|
|
978
|
+
if (positional[0] === "demo")
|
|
979
|
+
return projectDemoCommand(positional, flags, context);
|
|
974
980
|
return runProjectCommand(positional, flags, context);
|
|
975
981
|
case "assignment":
|
|
976
982
|
// The person's own lead (its lead token) claims as the lead; coordinator credentials keep their own path.
|
|
@@ -3465,6 +3471,7 @@ async function tickCommand(flags, context) {
|
|
|
3465
3471
|
onProviderSpawn: pid => { worktrees.recordProviderOccupancy(scoutLeased.worktree.path, runner, pid, scoutLeased.worktree.leaseEpoch); },
|
|
3466
3472
|
evidenceRoot: context.evidenceRoot,
|
|
3467
3473
|
answers: scoutAnswers,
|
|
3474
|
+
demoUrl: projectDemoUrl(context.databaseFile, repo),
|
|
3468
3475
|
provider: spec.provider,
|
|
3469
3476
|
model: proof.effective.model,
|
|
3470
3477
|
...(proof.effective.maxTurns === undefined ? {} : { maxTurns: proof.effective.maxTurns }),
|
|
@@ -12024,6 +12031,40 @@ async function projectConcurrencyCommand(positional, flags, context) {
|
|
|
12024
12031
|
...(changed.before > changed.after ? ["Builds already running carry on; the new number applies to the next one."] : []),
|
|
12025
12032
|
]);
|
|
12026
12033
|
}
|
|
12034
|
+
/** `project demo [<url>|off] --repo <p>` (review 827): the project's own demo or dev server, which its scouts may
|
|
12035
|
+
* screenshot. Changing it is an approver's act. */
|
|
12036
|
+
async function projectDemoCommand(positional, flags, context) {
|
|
12037
|
+
const command = "project demo";
|
|
12038
|
+
const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
|
|
12039
|
+
for (const name of flags.keys())
|
|
12040
|
+
if (!allowed.has(name))
|
|
12041
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a project demo option.`, EXIT.usage);
|
|
12042
|
+
const repoFlag = text(flags, "repo");
|
|
12043
|
+
if (positional.length > 2 || repoFlag === undefined)
|
|
12044
|
+
return fail(context.write, context.json, command, "usage", "Use project demo [<url>|off] --repo <project path>.", EXIT.usage);
|
|
12045
|
+
const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
|
|
12046
|
+
const known = [...new Set([...context.store.knownRepos(), ...("error" in registered ? [] : registered.repos)])];
|
|
12047
|
+
const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
|
|
12048
|
+
if (repo === undefined)
|
|
12049
|
+
return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
|
|
12050
|
+
const words = (url) => url === null ? `${repo}: no demo; scouts screenshot public pages only.` : `${repo}: scouts may also screenshot ${url}.`;
|
|
12051
|
+
const given = positional[1];
|
|
12052
|
+
if (given === undefined) {
|
|
12053
|
+
const current = projectDemoUrl(context.databaseFile, repo);
|
|
12054
|
+
return succeed(context.write, context.json, command, { repo, demoUrl: current }, () => [words(current)]);
|
|
12055
|
+
}
|
|
12056
|
+
const url = given.trim().toLowerCase() === "off" ? null : parseDemoUrl(given);
|
|
12057
|
+
if (url === null && given.trim().toLowerCase() !== "off")
|
|
12058
|
+
return fail(context.write, context.json, command, "usage", "The demo is an http or https address without a sign-in, or off.", EXIT.usage);
|
|
12059
|
+
const acting = await askCredentials(flags, context);
|
|
12060
|
+
const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
|
|
12061
|
+
if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
|
|
12062
|
+
return fail(context.write, context.json, command, "refused", "An approver for this project sets its demo: pass --as and --token (or use the remembered login).", EXIT.refused);
|
|
12063
|
+
}
|
|
12064
|
+
const changed = saveProjectDemo(context.databaseFile, repo, url);
|
|
12065
|
+
context.store.recordProjectDemo(acting.name, repo, changed.before, changed.after, context.clock());
|
|
12066
|
+
return succeed(context.write, context.json, command, { repo, before: changed.before, demoUrl: changed.after }, () => [words(changed.after)]);
|
|
12067
|
+
}
|
|
12027
12068
|
/** Write a pack or export where asked (never over an existing file), or to the terminal. */
|
|
12028
12069
|
function writeOut(context, command, out, content, summary, line, data) {
|
|
12029
12070
|
if (out === undefined) {
|
package/dist/project-delete.js
CHANGED
|
@@ -216,6 +216,8 @@ function deleteRows(store, repo, d, now) {
|
|
|
216
216
|
del(`${surface}_action`, IN("proposal"), proposals);
|
|
217
217
|
del(`${surface}_flow_action`, IN("card"), C);
|
|
218
218
|
del(`${surface}_flow_prompt`, IN("card"), C);
|
|
219
|
+
del(`${surface}_flow_choice`, IN("card"), C);
|
|
220
|
+
del(`${surface}_flow_note`, IN("card"), C);
|
|
219
221
|
del(`${surface}_question_action`, IN("question"), questions);
|
|
220
222
|
del(`${surface}_question_prompt`, IN("question"), questions);
|
|
221
223
|
del(`${surface}_ask_action`, IN("turn"), U);
|
|
@@ -226,6 +228,8 @@ function deleteRows(store, repo, d, now) {
|
|
|
226
228
|
del("telegram_proposal_action", IN("proposal"), proposals);
|
|
227
229
|
del("telegram_flow_action", IN("card"), C);
|
|
228
230
|
del("telegram_flow_prompt", IN("card"), C);
|
|
231
|
+
del("telegram_flow_choice", IN("card"), C);
|
|
232
|
+
del("flow_send", IN("card"), C);
|
|
229
233
|
del("telegram_question_action", IN("question"), questions);
|
|
230
234
|
del("telegram_question_prompt", IN("question"), questions);
|
|
231
235
|
del("chat_decide_action", IN("task_id"), K);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const projectDemoPath: (databaseFile: string) => string;
|
|
2
|
+
/** A demo URL as typed: an http(s) address with no sign-in details, at most 500 characters, or null. */
|
|
3
|
+
export declare function parseDemoUrl(value: string): string | null;
|
|
4
|
+
/** Every saved demo URL, by canonical project path. An unreadable file reads as none saved. */
|
|
5
|
+
export declare function savedProjectDemos(databaseFile: string): Map<string, string>;
|
|
6
|
+
/** The project's demo URL, or null when it has none. */
|
|
7
|
+
export declare function projectDemoUrl(databaseFile: string, repo: string): string | null;
|
|
8
|
+
/** Save (or, with null, clear) one project's demo URL atomically; returns it before and after. */
|
|
9
|
+
export declare function saveProjectDemo(databaseFile: string, repo: string, url: string | null): {
|
|
10
|
+
before: string | null;
|
|
11
|
+
after: string | null;
|
|
12
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project's own demo or dev server (review 827): the one address a scout
|
|
3
|
+
* may reach that is not on the public internet, so it can take screenshots
|
|
4
|
+
* of the project's UI. An approver sets it with
|
|
5
|
+
* `toolroll project demo --repo <p> <url>|off`; it is kept in a small file
|
|
6
|
+
* beside the database, like a project's builds-at-once number, and recorded
|
|
7
|
+
* in the ledger as before → after. A project without one has none.
|
|
8
|
+
*/
|
|
9
|
+
import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
export const projectDemoPath = (databaseFile) => join(dirname(databaseFile), "project-demo.json");
|
|
13
|
+
/** A demo URL as typed: an http(s) address with no sign-in details, at most 500 characters, or null. */
|
|
14
|
+
export function parseDemoUrl(value) {
|
|
15
|
+
const typed = value.trim();
|
|
16
|
+
if (typed === "" || typed.length > 500)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(typed);
|
|
20
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
21
|
+
return null;
|
|
22
|
+
if (url.username !== "" || url.password !== "")
|
|
23
|
+
return null;
|
|
24
|
+
return url.href;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Every saved demo URL, by canonical project path. An unreadable file reads as none saved. */
|
|
31
|
+
export function savedProjectDemos(databaseFile) {
|
|
32
|
+
const saved = new Map();
|
|
33
|
+
const file = projectDemoPath(databaseFile);
|
|
34
|
+
try {
|
|
35
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
36
|
+
if (stat === undefined || !stat.isFile() || stat.size > 262_144)
|
|
37
|
+
return saved;
|
|
38
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
39
|
+
const projects = value !== null && typeof value === "object" ? value.projects : undefined;
|
|
40
|
+
if (projects === null || typeof projects !== "object")
|
|
41
|
+
return saved;
|
|
42
|
+
for (const [repo, url] of Object.entries(projects)) {
|
|
43
|
+
const parsed = typeof url === "string" ? parseDemoUrl(url) : null;
|
|
44
|
+
if (parsed !== null)
|
|
45
|
+
saved.set(repo, parsed);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A damaged file never stops a scout: it simply has no demo to open.
|
|
50
|
+
}
|
|
51
|
+
return saved;
|
|
52
|
+
}
|
|
53
|
+
/** The project's demo URL, or null when it has none. */
|
|
54
|
+
export function projectDemoUrl(databaseFile, repo) {
|
|
55
|
+
return savedProjectDemos(databaseFile).get(repo) ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Save (or, with null, clear) one project's demo URL atomically; returns it before and after. */
|
|
58
|
+
export function saveProjectDemo(databaseFile, repo, url) {
|
|
59
|
+
const saved = savedProjectDemos(databaseFile);
|
|
60
|
+
const before = saved.get(repo) ?? null;
|
|
61
|
+
if (url === null)
|
|
62
|
+
saved.delete(repo);
|
|
63
|
+
else
|
|
64
|
+
saved.set(repo, url);
|
|
65
|
+
const file = projectDemoPath(databaseFile);
|
|
66
|
+
const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
67
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
68
|
+
writeFileSync(temp, `${JSON.stringify({ version: 1, projects: Object.fromEntries([...saved].sort(([a], [b]) => a.localeCompare(b))) }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
69
|
+
renameSync(temp, file);
|
|
70
|
+
return { before, after: url };
|
|
71
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -72,6 +72,12 @@ export type Invocation = {
|
|
|
72
72
|
* report rides the terminal result event instead of a file plan mode
|
|
73
73
|
* would refuse to write. Ignored by providers without `--json-schema`. */
|
|
74
74
|
jsonSchema?: Readonly<Record<string, unknown>>;
|
|
75
|
+
/** Claude tools allowed without asking, for a `dontAsk` launch: the scout's
|
|
76
|
+
* research and screenshot tools, and nothing that edits or runs commands. */
|
|
77
|
+
allowedTools?: readonly string[];
|
|
78
|
+
/** More MCP servers for this launch only, beside the project's own (the
|
|
79
|
+
* scout's headless browser). Claude-only; ignored for a review. */
|
|
80
|
+
extraMcpServers?: Readonly<Record<string, unknown>>;
|
|
75
81
|
};
|
|
76
82
|
export type ProviderRunner = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
|
|
77
83
|
/** The harness's own account of how a turn ended. */
|
package/dist/provider.js
CHANGED
|
@@ -229,7 +229,10 @@ const claudeArgv = (invocation) => [
|
|
|
229
229
|
// Headless (run 2085): a wakeup, cron job or monitor needs a later
|
|
230
230
|
// turn that a -p process never gets. Denied at the harness too.
|
|
231
231
|
"--disallowedTools", HEADLESS_DISALLOWED_TOOLS.join(","),
|
|
232
|
-
...(invocation.
|
|
232
|
+
...(invocation.allowedTools === undefined || invocation.allowedTools.length === 0 ? [] : ["--allowedTools", invocation.allowedTools.join(",")]),
|
|
233
|
+
...(invocation.toolArgv ?? []),
|
|
234
|
+
...(invocation.extraMcpServers === undefined ? [] : ["--mcp-config", JSON.stringify({ mcpServers: invocation.extraMcpServers })]),
|
|
235
|
+
...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : []),
|
|
233
236
|
...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
|
|
234
237
|
]),
|
|
235
238
|
];
|
package/dist/report-summary.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
|
+
import type { ParsedReport } from "./scout-report.js";
|
|
2
3
|
/** The report a scout delivered, for get_task: title, summary, follow-ups —
|
|
3
4
|
* verified before it is read; the document itself stays on the task page. */
|
|
4
5
|
export declare function reportSummaryFor(store: Store, evidenceRoot: string | undefined, taskRef: number): {
|
|
@@ -11,3 +12,11 @@ export declare function reportSummaryFor(store: Store, evidenceRoot: string | un
|
|
|
11
12
|
} | {
|
|
12
13
|
problem: string;
|
|
13
14
|
} | null;
|
|
15
|
+
/** The longest full report a later flow step is given through {{stage.<id>.report}}. */
|
|
16
|
+
export declare const REPORT_FILL_LIMIT = 20000;
|
|
17
|
+
/** What a report zone gives the zones after it besides its summary: its items as a numbered list (title, why, URL)
|
|
18
|
+
* and the report's markdown, capped. Empty strings when it has neither. */
|
|
19
|
+
export declare function reportFillIns(report: Pick<ParsedReport, "items" | "report">): {
|
|
20
|
+
items: string;
|
|
21
|
+
report: string;
|
|
22
|
+
};
|
package/dist/report-summary.js
CHANGED
|
@@ -13,3 +13,12 @@ export function reportSummaryFor(store, evidenceRoot, taskRef) {
|
|
|
13
13
|
return { problem: view.problem };
|
|
14
14
|
return { title: view.report.title, summary: view.report.summary, followUps: view.report.followUps };
|
|
15
15
|
}
|
|
16
|
+
/** The longest full report a later flow step is given through {{stage.<id>.report}}. */
|
|
17
|
+
export const REPORT_FILL_LIMIT = 20_000;
|
|
18
|
+
/** What a report zone gives the zones after it besides its summary: its items as a numbered list (title, why, URL)
|
|
19
|
+
* and the report's markdown, capped. Empty strings when it has neither. */
|
|
20
|
+
export function reportFillIns(report) {
|
|
21
|
+
const items = report.items.map((one, index) => `${index + 1}. ${one.title}\n ${one.why.replace(/\s*\n\s*/g, " ")}\n ${one.url}`).join("\n");
|
|
22
|
+
const document = report.report.length <= REPORT_FILL_LIMIT ? report.report : `${report.report.slice(0, REPORT_FILL_LIMIT)}\n… (the rest of the report is on its task)`;
|
|
23
|
+
return { items, report: document };
|
|
24
|
+
}
|
package/dist/result-shots.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type ResultShotsPlan =
|
|
|
39
39
|
* no wait in between, or asks again.
|
|
40
40
|
*/
|
|
41
41
|
export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run">, sent?: ReadonlySet<number>): ResultShotsPlan;
|
|
42
|
+
/** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
|
|
43
|
+
export declare function isShotsKind(kind: string): boolean;
|
|
42
44
|
/** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
|
|
43
45
|
export declare function shotWaitsUntil(resultNextAt: unknown, now: Date): string;
|
|
44
46
|
/** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
|
package/dist/result-shots.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
-
import { imageDimensions, readVerifiedProofForRun, RETENTION_NOTE } from "./evidence.js";
|
|
18
|
+
import { imageDimensions, readVerifiedProofForRun, readVerifiedReport, RETENTION_NOTE } from "./evidence.js";
|
|
19
19
|
import { resultImageFileName, verifyResultImage } from "./chat-evidence.js";
|
|
20
20
|
import { publicChatText } from "./chat-display.js";
|
|
21
21
|
import { chatTitle } from "./chat-voice.js";
|
|
22
|
-
import { RESULT_SHOTS_KIND } from "./store.js";
|
|
22
|
+
import { FLOW_SHOTS_KIND, RESULT_SHOTS_KIND } from "./store.js";
|
|
23
23
|
/** "Up to 4": the most screenshots one result ever sends. */
|
|
24
24
|
export const RESULT_SHOTS_MAX = 4;
|
|
25
25
|
/** The choices in the order Settings and the CLI show them. */
|
|
@@ -45,6 +45,19 @@ function ownCaptions(store, root, run) {
|
|
|
45
45
|
}
|
|
46
46
|
return captions;
|
|
47
47
|
}
|
|
48
|
+
/** A scout's screenshots keep the captions its report gave them, by evidence id. */
|
|
49
|
+
function reportCaptions(store, root, taskRef, run) {
|
|
50
|
+
const captions = new Map();
|
|
51
|
+
const view = readVerifiedReport(store, root, taskRef);
|
|
52
|
+
if (view === null || !view.ok || view.run !== run)
|
|
53
|
+
return captions;
|
|
54
|
+
for (const image of view.report.images) {
|
|
55
|
+
const words = publicChatText(image.caption, 200).trim();
|
|
56
|
+
if (image.artifact !== undefined && words !== "" && !words.includes("[sensitive text hidden]"))
|
|
57
|
+
captions.set(image.artifact, words);
|
|
58
|
+
}
|
|
59
|
+
return captions;
|
|
60
|
+
}
|
|
48
61
|
function isPhoto(bytes, format) {
|
|
49
62
|
const size = imageDimensions(bytes, format);
|
|
50
63
|
if (size === null || size.width === 0 || size.height === 0)
|
|
@@ -59,9 +72,10 @@ function isPhoto(bytes, format) {
|
|
|
59
72
|
* no wait in between, or asks again.
|
|
60
73
|
*/
|
|
61
74
|
export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()) {
|
|
62
|
-
if (row.kind
|
|
75
|
+
if (!isShotsKind(row.kind) || row.recipient === null || row.taskId === null || row.taskRef === null || row.run === null)
|
|
63
76
|
return { kind: "none", why: "none" };
|
|
64
|
-
|
|
77
|
+
// A flow's "Send to me" or "Person chooses" asked for them: up to four, whatever the person chose for results.
|
|
78
|
+
const limit = row.kind === FLOW_SHOTS_KIND ? RESULT_SHOTS_MAX : resultShotLimit(store.notificationPreference(row.recipient).screenshots);
|
|
65
79
|
if (limit === 0)
|
|
66
80
|
return { kind: "none", why: "off" };
|
|
67
81
|
const title = chatTitle(store, row.taskId);
|
|
@@ -82,6 +96,7 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
82
96
|
if (chosen.length === 0)
|
|
83
97
|
return { kind: "none", why: "none" };
|
|
84
98
|
const captions = ownCaptions(store, evidenceRoot, row.run);
|
|
99
|
+
const reported = latest.role === "scout" ? reportCaptions(store, evidenceRoot, row.taskRef, row.run) : new Map();
|
|
85
100
|
const shots = [];
|
|
86
101
|
for (const [index, artifact] of chosen.entries()) {
|
|
87
102
|
const verified = verifyResultImage(store, evidenceRoot, repos, { taskId: row.taskId, run: row.run, artifact: artifact.id, sha256: artifact.sha256 });
|
|
@@ -91,7 +106,7 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
91
106
|
return { kind: "none", why: "pruned" };
|
|
92
107
|
return line(verified.problem);
|
|
93
108
|
}
|
|
94
|
-
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? null;
|
|
109
|
+
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? reported.get(artifact.id) ?? null;
|
|
95
110
|
shots.push({
|
|
96
111
|
artifact: artifact.id, sha256: artifact.sha256, bytes: verified.bytes, format: verified.format,
|
|
97
112
|
fileName: resultImageFileName(row.taskId, row.run, artifact.id, verified.format),
|
|
@@ -102,6 +117,10 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
102
117
|
const remaining = shots.filter(one => !sent.has(one.artifact));
|
|
103
118
|
return remaining.length === 0 ? { kind: "none", why: "sent" } : { kind: "send", taskId: row.taskId, run: row.run, shots: remaining };
|
|
104
119
|
}
|
|
120
|
+
/** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
|
|
121
|
+
export function isShotsKind(kind) {
|
|
122
|
+
return kind === RESULT_SHOTS_KIND || kind === FLOW_SHOTS_KIND;
|
|
123
|
+
}
|
|
105
124
|
/** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
|
|
106
125
|
export function shotWaitsUntil(resultNextAt, now) {
|
|
107
126
|
const soonest = new Date(now.getTime() + 1_000).toISOString();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type BrowserLaunch = {
|
|
2
|
+
command: string;
|
|
3
|
+
args: readonly string[];
|
|
4
|
+
};
|
|
5
|
+
export type ScoutBrowserServer = {
|
|
6
|
+
url: string;
|
|
7
|
+
close: () => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
export type BrowserStart = {
|
|
10
|
+
ok: true;
|
|
11
|
+
server: ScoutBrowserServer;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
export type BrowserStarter = (launch: BrowserLaunch, options: BrowserOptions) => Promise<BrowserStart>;
|
|
17
|
+
export type BrowserOptions = {
|
|
18
|
+
/** The browser tool's environment: the scout's own, its proxy settings included. */
|
|
19
|
+
env: Record<string, string | undefined>;
|
|
20
|
+
/** Where it saves screenshots (its `--output-dir`), also its working folder. */
|
|
21
|
+
folder: string;
|
|
22
|
+
/** The whole start and check, from spawn to screenshot (review 828: at most 60 seconds). */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
};
|
|
25
|
+
/** The public page the check opens through the scout's proxy. */
|
|
26
|
+
export declare const PREFLIGHT_PAGE = "https://example.com/";
|
|
27
|
+
/** How a scout gets its browser: started for real, or, for a test's stub agent, a placeholder that starts nothing. */
|
|
28
|
+
export declare function starterFor(agentInjected: boolean): BrowserStarter;
|
|
29
|
+
/** Start the browser tool outside the fence and check it opens a public page and saves a screenshot. */
|
|
30
|
+
export declare function startScoutBrowser(launch: BrowserLaunch, options: BrowserOptions): Promise<BrowserStart>;
|
|
31
|
+
export type McpSession = {
|
|
32
|
+
start: () => Promise<{
|
|
33
|
+
ok: true;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: string;
|
|
37
|
+
}>;
|
|
38
|
+
call: (tool: string, input: Record<string, unknown>) => Promise<{
|
|
39
|
+
ok: true;
|
|
40
|
+
result: Record<string, unknown>;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
reason: string;
|
|
44
|
+
}>;
|
|
45
|
+
close: () => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
/** A client of the browser's HTTP transport, as the scout's own is: every step answers by `deadline`, or says why not. */
|
|
48
|
+
export declare function openSession(url: string, deadline: number, late: string): McpSession;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scout's browser (run 2356, review 828). Chrome's own sandbox can't start inside the agent fence (nested
|
|
3
|
+
* Seatbelt: every page crashed, even example.com), and switching that sandbox off is not an option. So the browser
|
|
4
|
+
* tool runs beside the agent instead of under it: the worker starts the pinned Playwright MCP itself, outside the
|
|
5
|
+
* fence with Chrome's sandbox on, serving its HTTP transport on a random loopback port, and the fenced agent gets
|
|
6
|
+
* only that URL. Every page still goes through the scout's public-web-only proxy, and screenshots land in the run's
|
|
7
|
+
* image folder. Before research begins the browser is checked once, through that proxy, on a public page, with one
|
|
8
|
+
* time limit for the whole check; a browser that can't start is said in one line and the scout researches
|
|
9
|
+
* without it.
|
|
10
|
+
*
|
|
11
|
+
* Playwright MCP 0.0.82 has no per-client token for its HTTP transport; it answers only on 127.0.0.1, only for
|
|
12
|
+
* that host name (its own host check), and only while this run's scout does.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { request as httpRequest } from "node:http";
|
|
16
|
+
import { connect, createServer } from "node:net";
|
|
17
|
+
import { readdirSync, rmSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
/** The public page the check opens through the scout's proxy. */
|
|
20
|
+
export const PREFLIGHT_PAGE = "https://example.com/";
|
|
21
|
+
const PREFLIGHT_TIMEOUT_MS = 60_000;
|
|
22
|
+
const CLOSE_GRACE_MS = 5_000;
|
|
23
|
+
const IMAGE = /\.(png|jpe?g)$/i;
|
|
24
|
+
/** How a scout gets its browser: started for real, or, for a test's stub agent, a placeholder that starts nothing. */
|
|
25
|
+
export function starterFor(agentInjected) {
|
|
26
|
+
return agentInjected ? unstarted : startScoutBrowser;
|
|
27
|
+
}
|
|
28
|
+
const unstarted = async () => ({ ok: true, server: { url: "http://127.0.0.1:9/mcp", close: async () => undefined } });
|
|
29
|
+
/** Start the browser tool outside the fence and check it opens a public page and saves a screenshot. */
|
|
30
|
+
export async function startScoutBrowser(launch, options) {
|
|
31
|
+
const timeoutMs = options.timeoutMs ?? PREFLIGHT_TIMEOUT_MS;
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
const late = `the browser did not start within ${Math.round(timeoutMs / 1000)} seconds`;
|
|
34
|
+
const served = await serveBrowser(launch, options, deadline, late);
|
|
35
|
+
if (!served.ok)
|
|
36
|
+
return served;
|
|
37
|
+
const before = new Set(entries(options.folder));
|
|
38
|
+
const session = openSession(served.server.url, deadline, late);
|
|
39
|
+
try {
|
|
40
|
+
let checked = await preflight(session);
|
|
41
|
+
if (checked.ok && !entries(options.folder).some(name => !before.has(name) && IMAGE.test(name)))
|
|
42
|
+
checked = { ok: false, reason: "the browser saved no screenshot" };
|
|
43
|
+
if (!checked.ok) {
|
|
44
|
+
await served.server.close();
|
|
45
|
+
return checked;
|
|
46
|
+
}
|
|
47
|
+
return served;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await session.close();
|
|
51
|
+
// The check's own screenshot and page notes are not the scout's.
|
|
52
|
+
for (const name of entries(options.folder))
|
|
53
|
+
if (!before.has(name))
|
|
54
|
+
rmSync(join(options.folder, name), { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function preflight(session) {
|
|
58
|
+
const started = await session.start();
|
|
59
|
+
if (!started.ok)
|
|
60
|
+
return started;
|
|
61
|
+
for (const [tool, input] of [["browser_navigate", { url: PREFLIGHT_PAGE }], ["browser_take_screenshot", { type: "png" }]]) {
|
|
62
|
+
const done = await session.call(tool, input);
|
|
63
|
+
if (!done.ok)
|
|
64
|
+
return done;
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
/** Spawn the tool with its HTTP transport on a free 127.0.0.1 port and wait until it answers there. */
|
|
69
|
+
async function serveBrowser(launch, options, deadline, late) {
|
|
70
|
+
const port = await freePort();
|
|
71
|
+
const child = spawn(launch.command, [...launch.args, "--host", "127.0.0.1", "--port", String(port), "--allowed-hosts", `127.0.0.1:${port}`], {
|
|
72
|
+
cwd: options.folder,
|
|
73
|
+
env: options.env,
|
|
74
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
75
|
+
detached: process.platform !== "win32",
|
|
76
|
+
});
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let stopped = null;
|
|
79
|
+
child.on("error", error => { stopped ??= `the browser tool could not run (${error.message})`; });
|
|
80
|
+
child.on("exit", (code, sig) => { stopped ??= `the browser tool stopped (${sig ?? `exit ${code}`})${lastLine(stderr) === "" ? "" : `: ${lastLine(stderr)}`}`; });
|
|
81
|
+
child.stderr.on("data", chunk => { stderr = (stderr + String(chunk)).slice(-4_000); });
|
|
82
|
+
const close = async () => {
|
|
83
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
84
|
+
const exited = new Promise(done => child.once("exit", () => done()));
|
|
85
|
+
signal(child.pid, "SIGTERM");
|
|
86
|
+
await Promise.race([exited, new Promise(done => setTimeout(done, CLOSE_GRACE_MS).unref())]);
|
|
87
|
+
}
|
|
88
|
+
// Its browser and helpers go with it.
|
|
89
|
+
signal(child.pid, "SIGKILL");
|
|
90
|
+
};
|
|
91
|
+
// First runs download the pinned tool; it listens once it is ready.
|
|
92
|
+
while (stopped === null && Date.now() < deadline && !(await answers(port)))
|
|
93
|
+
await new Promise(done => setTimeout(done, 250));
|
|
94
|
+
if (stopped !== null || Date.now() >= deadline) {
|
|
95
|
+
const reason = stopped ?? late;
|
|
96
|
+
await close();
|
|
97
|
+
return { ok: false, reason };
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, server: { url: `http://127.0.0.1:${port}/mcp`, close } };
|
|
100
|
+
}
|
|
101
|
+
function freePort() {
|
|
102
|
+
return new Promise((done, fail) => {
|
|
103
|
+
const probe = createServer();
|
|
104
|
+
probe.once("error", fail);
|
|
105
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
106
|
+
const address = probe.address();
|
|
107
|
+
probe.close(() => (typeof address === "object" && address !== null ? done(address.port) : fail(new Error("no free port"))));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function answers(port) {
|
|
112
|
+
return new Promise(done => {
|
|
113
|
+
const socket = connect({ host: "127.0.0.1", port });
|
|
114
|
+
socket.setTimeout(1_000);
|
|
115
|
+
socket.once("connect", () => { socket.destroy(); done(true); });
|
|
116
|
+
socket.once("error", () => done(false));
|
|
117
|
+
socket.once("timeout", () => { socket.destroy(); done(false); });
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function signal(pid, name) {
|
|
121
|
+
if (pid === undefined)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
process.kill(process.platform === "win32" ? pid : -pid, name);
|
|
125
|
+
}
|
|
126
|
+
catch { /* already gone */ }
|
|
127
|
+
}
|
|
128
|
+
function entries(folder) {
|
|
129
|
+
try {
|
|
130
|
+
return readdirSync(folder);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** A client of the browser's HTTP transport, as the scout's own is: every step answers by `deadline`, or says why not. */
|
|
137
|
+
export function openSession(url, deadline, late) {
|
|
138
|
+
let session = null;
|
|
139
|
+
let next = 0;
|
|
140
|
+
const post = (body, method = "POST") => new Promise(done => {
|
|
141
|
+
const remaining = deadline - Date.now();
|
|
142
|
+
if (remaining <= 0)
|
|
143
|
+
return done({ ok: false, reason: late });
|
|
144
|
+
const headers = { accept: "application/json, text/event-stream", "content-type": "application/json" };
|
|
145
|
+
if (session !== null)
|
|
146
|
+
headers["mcp-session-id"] = session;
|
|
147
|
+
const sent = httpRequest(url, { method, headers }, answer => {
|
|
148
|
+
let text = "";
|
|
149
|
+
answer.on("data", chunk => { text += String(chunk); });
|
|
150
|
+
answer.on("end", () => { clearTimeout(timer); done({ ok: true, status: answer.statusCode ?? 0, headers: answer.headers, text }); });
|
|
151
|
+
answer.on("error", error => { clearTimeout(timer); done({ ok: false, reason: `the browser stopped answering (${error.message})` }); });
|
|
152
|
+
});
|
|
153
|
+
const timer = setTimeout(() => { sent.destroy(); done({ ok: false, reason: late }); }, remaining);
|
|
154
|
+
sent.on("error", error => { clearTimeout(timer); done({ ok: false, reason: `the browser stopped answering (${error.message})` }); });
|
|
155
|
+
sent.end(method === "POST" ? JSON.stringify(body) : undefined);
|
|
156
|
+
});
|
|
157
|
+
const request = async (method, params) => {
|
|
158
|
+
const id = ++next;
|
|
159
|
+
const sent = await post({ jsonrpc: "2.0", id, method, params });
|
|
160
|
+
if (!sent.ok)
|
|
161
|
+
return sent;
|
|
162
|
+
const message = messages(sent.text).find(one => one["id"] === id);
|
|
163
|
+
if (message === undefined)
|
|
164
|
+
return { ok: false, reason: `the browser tool answered ${sent.status}${sent.text.trim() === "" ? "" : `: ${oneLine(sent.text)}`}` };
|
|
165
|
+
const header = sent.headers["mcp-session-id"];
|
|
166
|
+
if (typeof header === "string")
|
|
167
|
+
session = header;
|
|
168
|
+
return { ok: true, message };
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
start: async () => {
|
|
172
|
+
const hello = await request("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "toolroll-scout-preflight", version: "1" } });
|
|
173
|
+
if (!hello.ok)
|
|
174
|
+
return hello;
|
|
175
|
+
if (hello.message["error"] !== undefined)
|
|
176
|
+
return { ok: false, reason: `the browser tool refused to start: ${errorText(hello.message)}` };
|
|
177
|
+
const ready = await post({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
178
|
+
return ready.ok ? { ok: true } : ready;
|
|
179
|
+
},
|
|
180
|
+
call: async (tool, input) => {
|
|
181
|
+
const answer = await request("tools/call", { name: tool, arguments: input });
|
|
182
|
+
if (!answer.ok)
|
|
183
|
+
return answer;
|
|
184
|
+
const failed = toolFailure(answer.message);
|
|
185
|
+
return failed === null ? { ok: true, result: (answer.message["result"] ?? {}) } : { ok: false, reason: failed };
|
|
186
|
+
},
|
|
187
|
+
close: async () => {
|
|
188
|
+
// Ending the session closes its browser context.
|
|
189
|
+
if (session !== null)
|
|
190
|
+
await post({}, "DELETE");
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** The JSON-RPC messages in an answer: a plain JSON body, or the data lines of an event stream. */
|
|
195
|
+
function messages(text) {
|
|
196
|
+
const bodies = text.trimStart().startsWith("{") ? [text] : text.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5));
|
|
197
|
+
return bodies.flatMap(body => {
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(body);
|
|
200
|
+
return typeof parsed === "object" && parsed !== null ? [parsed] : [];
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Why a tool call failed, in one line, or null when it worked. */
|
|
208
|
+
function toolFailure(answer) {
|
|
209
|
+
if (answer["error"] !== undefined)
|
|
210
|
+
return errorText(answer);
|
|
211
|
+
const result = answer["result"];
|
|
212
|
+
if (result?.isError !== true)
|
|
213
|
+
return null;
|
|
214
|
+
const text = result.content?.map(one => (typeof one.text === "string" ? one.text : "")).join("\n") ?? "";
|
|
215
|
+
const line = text.split("\n").map(one => one.replace(/^#+\s*Error\s*$/i, "").replace(/^Error:\s*/, "").replace(/^browserBackend\.callTool:\s*/, "").trim()).find(one => one !== "");
|
|
216
|
+
return oneLine(line ?? "unknown error");
|
|
217
|
+
}
|
|
218
|
+
function errorText(answer) {
|
|
219
|
+
const error = answer["error"];
|
|
220
|
+
return oneLine(typeof error?.message === "string" ? error.message : "unknown error");
|
|
221
|
+
}
|
|
222
|
+
function lastLine(text) {
|
|
223
|
+
return oneLine(text.trim().split("\n").at(-1) ?? "");
|
|
224
|
+
}
|
|
225
|
+
function oneLine(text) {
|
|
226
|
+
// eslint-disable-next-line no-control-regex
|
|
227
|
+
return text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, 200);
|
|
228
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Socket } from "node:net";
|
|
2
|
+
/** Whether an IP literal is a public internet address. IPv4 inside IPv6 (mapped, compatible or NAT64) is judged as IPv4. */
|
|
3
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
4
|
+
export type Lookup = (host: string) => Promise<readonly string[]>;
|
|
5
|
+
/**
|
|
6
|
+
* A connection to the first of the checked addresses that answers, tried in turn (review 827): a host whose IPv6
|
|
7
|
+
* address is unreachable from here is still reached over IPv4. Null when none answers.
|
|
8
|
+
*/
|
|
9
|
+
export declare function connectFirst(addresses: readonly string[], port: number, timeoutMs?: number): Promise<Socket | null>;
|
|
10
|
+
export type ScoutProxy = {
|
|
11
|
+
url: string;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Start the scout browser's proxy on loopback. `refused` hears every request it turned away (for the live log and
|
|
16
|
+
* tests). `lookup` resolves a host name to its addresses (the system resolver unless a test supplies one).
|
|
17
|
+
*/
|
|
18
|
+
export declare function startScoutProxy(options?: {
|
|
19
|
+
demoUrl?: string | null;
|
|
20
|
+
lookup?: Lookup;
|
|
21
|
+
refused?: (target: string, why: string) => void;
|
|
22
|
+
}): Promise<ScoutProxy>;
|