toolroll 0.9.20 → 0.9.21

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +22 -21
  4. package/dist/browser-workspace.d.ts +41 -1
  5. package/dist/chat-actions.d.ts +5 -0
  6. package/dist/chat-actions.js +57 -6
  7. package/dist/chat-delivery-state.d.ts +15 -0
  8. package/dist/chat-delivery-state.js +27 -2
  9. package/dist/chat-delivery.js +24 -4
  10. package/dist/chat-flow.d.ts +26 -3
  11. package/dist/chat-flow.js +217 -5
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/discord-chat.js +2 -2
  14. package/dist/evidence.d.ts +13 -1
  15. package/dist/evidence.js +10 -1
  16. package/dist/flow-engine.d.ts +7 -0
  17. package/dist/flow-engine.js +71 -27
  18. package/dist/flow-gallery-ui.d.ts +1 -1
  19. package/dist/flow-gallery-ui.js +8 -7
  20. package/dist/flow-gallery.d.ts +8 -0
  21. package/dist/flow-gallery.js +37 -3
  22. package/dist/flow-send.d.ts +80 -0
  23. package/dist/flow-send.js +249 -0
  24. package/dist/flow-share.js +7 -3
  25. package/dist/flow-steps.js +1 -1
  26. package/dist/flows-cli.js +11 -3
  27. package/dist/flows-ui.js +20 -4
  28. package/dist/flows.d.ts +27 -1
  29. package/dist/flows.js +104 -18
  30. package/dist/mate-tools.js +33 -10
  31. package/dist/operate.js +41 -0
  32. package/dist/project-delete.js +4 -0
  33. package/dist/project-demo.d.ts +12 -0
  34. package/dist/project-demo.js +71 -0
  35. package/dist/provider.d.ts +6 -0
  36. package/dist/provider.js +4 -1
  37. package/dist/report-summary.d.ts +9 -0
  38. package/dist/report-summary.js +9 -0
  39. package/dist/result-shots.d.ts +2 -0
  40. package/dist/result-shots.js +24 -5
  41. package/dist/scout-net.d.ts +22 -0
  42. package/dist/scout-net.js +214 -0
  43. package/dist/scout-report.d.ts +67 -0
  44. package/dist/scout-report.js +108 -1
  45. package/dist/scout.d.ts +42 -0
  46. package/dist/scout.js +225 -16
  47. package/dist/serve.js +43 -5
  48. package/dist/slack-chat.js +3 -3
  49. package/dist/store.d.ts +53 -0
  50. package/dist/store.js +95 -4
  51. package/dist/teams-chat.js +1 -1
  52. package/dist/telegram-flow.d.ts +16 -2
  53. package/dist/telegram-flow.js +58 -0
  54. package/dist/telegram.js +46 -10
  55. 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) {
@@ -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
+ }
@@ -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.toolArgv ?? []), ...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : []),
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
  ];
@@ -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
+ };
@@ -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
+ }
@@ -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. */
@@ -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 !== RESULT_SHOTS_KIND || row.recipient === null || row.taskId === null || row.taskRef === null || row.run === null)
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
- const limit = resultShotLimit(store.notificationPreference(row.recipient).screenshots);
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,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>;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The scout reaches only the public web (reviews 826, 827): every request
3
+ * its browser and its web fetch make goes through this local proxy, which resolves the host
4
+ * itself and refuses anything that is not a public internet address —
5
+ * loopback, private, link-local, carrier-grade NAT, multicast and reserved
6
+ * ranges, in IPv4 and IPv6 — then connects to the very addresses it
7
+ * checked, each in turn until one answers, so a name that re-resolves
8
+ * somewhere private gets nowhere. The one
9
+ * exception is the project's own demo URL, when one is set, matched by its
10
+ * exact scheme, host and port. Redirects and a page's own requests pass
11
+ * through the same check; file: never reaches a proxy, and the pinned
12
+ * Playwright refuses to open it.
13
+ */
14
+ import { createServer, request as httpRequest } from "node:http";
15
+ import { BlockList, connect, isIP } from "node:net";
16
+ import { lookup as dnsLookup } from "node:dns/promises";
17
+ /** Address ranges a scout's browser may never reach. */
18
+ const NOT_PUBLIC = (() => {
19
+ const list = new BlockList();
20
+ for (const [net, prefix] of [
21
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
22
+ ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
23
+ ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
24
+ ])
25
+ list.addSubnet(net, prefix, "ipv4");
26
+ for (const [net, prefix] of [
27
+ ["::", 128], ["::1", 128], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8],
28
+ ])
29
+ list.addSubnet(net, prefix, "ipv6");
30
+ return list;
31
+ })();
32
+ /** Whether an IP literal is a public internet address. IPv4 inside IPv6 (mapped, compatible or NAT64) is judged as IPv4. */
33
+ export function isPublicAddress(address) {
34
+ const bare = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
35
+ const family = isIP(bare);
36
+ if (family === 4)
37
+ return !NOT_PUBLIC.check(bare, "ipv4");
38
+ if (family !== 6)
39
+ return false;
40
+ const embedded = /^(?:::ffff:(?:0:)?|::|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/i.exec(bare)?.[1];
41
+ if (embedded !== undefined)
42
+ return isPublicAddress(embedded);
43
+ const mappedHex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(bare);
44
+ if (mappedHex !== null) {
45
+ const high = parseInt(mappedHex[1], 16);
46
+ const low = parseInt(mappedHex[2], 16);
47
+ return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
48
+ }
49
+ if (/^::ffff:/i.test(bare))
50
+ return false;
51
+ return !NOT_PUBLIC.check(bare, "ipv6");
52
+ }
53
+ const systemLookup = async (host) => (await dnsLookup(host, { all: true, verbatim: true })).map(one => one.address);
54
+ /** The demo URL's own origin, or null when none is set or it is not an http(s) address. */
55
+ function demoTarget(demoUrl) {
56
+ if (demoUrl === undefined || demoUrl === null || demoUrl === "")
57
+ return null;
58
+ try {
59
+ const url = new URL(demoUrl);
60
+ if (url.protocol !== "http:" && url.protocol !== "https:")
61
+ return null;
62
+ const scheme = url.protocol === "http:" ? "http" : "https";
63
+ return { scheme, host: url.hostname.toLowerCase(), port: Number(url.port || (scheme === "http" ? 80 : 443)) };
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /** How long one address may take to answer before the next is tried. */
70
+ const CONNECT_TIMEOUT_MS = 10_000;
71
+ /**
72
+ * A connection to the first of the checked addresses that answers, tried in turn (review 827): a host whose IPv6
73
+ * address is unreachable from here is still reached over IPv4. Null when none answers.
74
+ */
75
+ export async function connectFirst(addresses, port, timeoutMs = CONNECT_TIMEOUT_MS) {
76
+ for (const address of addresses) {
77
+ const socket = await new Promise(done => {
78
+ const attempt = connect({ host: address, port });
79
+ const give = (value) => {
80
+ attempt.removeAllListeners("connect").removeAllListeners("error").removeAllListeners("timeout");
81
+ attempt.setTimeout(0);
82
+ if (value === null)
83
+ attempt.destroy();
84
+ done(value);
85
+ };
86
+ attempt.setTimeout(timeoutMs);
87
+ attempt.once("connect", () => give(attempt));
88
+ attempt.once("error", () => give(null));
89
+ attempt.once("timeout", () => give(null));
90
+ });
91
+ if (socket !== null)
92
+ return socket;
93
+ }
94
+ return null;
95
+ }
96
+ /**
97
+ * Start the scout browser's proxy on loopback. `refused` hears every request it turned away (for the live log and
98
+ * tests). `lookup` resolves a host name to its addresses (the system resolver unless a test supplies one).
99
+ */
100
+ export async function startScoutProxy(options = {}) {
101
+ const lookup = options.lookup ?? systemLookup;
102
+ const demo = demoTarget(options.demoUrl);
103
+ const sockets = new Set();
104
+ /** The addresses this request may reach, in the resolver's order, or why it may not. */
105
+ const resolve = async (target) => {
106
+ const host = target.host.replace(/^\[|\]$/g, "").toLowerCase();
107
+ const isDemo = demo !== null && demo.scheme === target.scheme && demo.host.replace(/^\[|\]$/g, "") === host && demo.port === target.port;
108
+ let addresses;
109
+ try {
110
+ addresses = isIP(host) !== 0 ? [host] : await lookup(host);
111
+ }
112
+ catch {
113
+ return { ok: false, why: "the host does not resolve" };
114
+ }
115
+ if (addresses.length === 0)
116
+ return { ok: false, why: "the host does not resolve" };
117
+ if (isDemo)
118
+ return { ok: true, addresses };
119
+ if (!addresses.every(isPublicAddress))
120
+ return { ok: false, why: "not a public internet address" };
121
+ return { ok: true, addresses };
122
+ };
123
+ const refuse = (target, why) => {
124
+ try {
125
+ options.refused?.(target, why);
126
+ }
127
+ catch { /* a listener's trouble is not the browser's */ }
128
+ };
129
+ const server = createServer((incoming, outgoing) => {
130
+ void (async () => {
131
+ let url;
132
+ try {
133
+ url = new URL(incoming.url ?? "");
134
+ }
135
+ catch {
136
+ outgoing.writeHead(400).end();
137
+ return;
138
+ }
139
+ if (url.protocol !== "http:") {
140
+ refuse(url.protocol, "only web pages");
141
+ outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
142
+ return;
143
+ }
144
+ const target = { scheme: "http", host: url.hostname, port: Number(url.port || 80) };
145
+ const reached = await resolve(target);
146
+ if (!reached.ok) {
147
+ refuse(url.origin, reached.why);
148
+ outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
149
+ return;
150
+ }
151
+ const upstream = await connectFirst(reached.addresses, target.port);
152
+ if (upstream === null) {
153
+ outgoing.writeHead(502).end();
154
+ return;
155
+ }
156
+ sockets.add(upstream);
157
+ upstream.on("close", () => sockets.delete(upstream));
158
+ const headers = { ...incoming.headers };
159
+ delete headers["proxy-connection"];
160
+ delete headers["proxy-authorization"];
161
+ const forward = httpRequest({ createConnection: () => upstream, method: incoming.method, path: `${url.pathname}${url.search}`, headers, setHost: false }, answer => {
162
+ outgoing.writeHead(answer.statusCode ?? 502, answer.headers);
163
+ answer.pipe(outgoing);
164
+ });
165
+ forward.on("error", () => { if (!outgoing.headersSent)
166
+ outgoing.writeHead(502); outgoing.end(); });
167
+ incoming.pipe(forward);
168
+ })();
169
+ });
170
+ server.on("connect", (incoming, client, head) => {
171
+ sockets.add(client);
172
+ client.on("close", () => sockets.delete(client));
173
+ client.on("error", () => client.destroy());
174
+ void (async () => {
175
+ const match = /^(\[[0-9a-fA-F:.]+\]|[^:[\]]+):(\d{1,5})$/.exec(incoming.url ?? "");
176
+ const target = match === null ? null : { scheme: "https", host: match[1], port: Number(match[2]) };
177
+ const reached = target === null ? { ok: false, why: "not a host and port" } : await resolve(target);
178
+ if (!reached.ok) {
179
+ refuse(incoming.url ?? "", reached.why);
180
+ client.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
181
+ return;
182
+ }
183
+ const upstream = await connectFirst(reached.addresses, target.port);
184
+ if (upstream === null) {
185
+ if (!client.destroyed)
186
+ client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
187
+ return;
188
+ }
189
+ sockets.add(upstream);
190
+ upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
191
+ upstream.on("error", () => client.destroy());
192
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
193
+ if (head.length > 0)
194
+ upstream.write(head);
195
+ upstream.pipe(client);
196
+ client.pipe(upstream);
197
+ })();
198
+ });
199
+ server.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
200
+ await new Promise((done, fail) => {
201
+ server.once("error", fail);
202
+ server.listen(0, "127.0.0.1", () => { server.off("error", fail); done(); });
203
+ });
204
+ const address = server.address();
205
+ const port = typeof address === "object" && address !== null ? address.port : 0;
206
+ return {
207
+ url: `http://127.0.0.1:${port}`,
208
+ close: () => new Promise(done => {
209
+ for (const socket of sockets)
210
+ socket.destroy();
211
+ server.close(() => done());
212
+ }),
213
+ };
214
+ }
@@ -20,6 +20,24 @@ export type ParsedReport = {
20
20
  title: string;
21
21
  goal: string;
22
22
  }[];
23
+ /** What the scout found, as a short list later steps can read: each cites its URL, and may show one of `images`. */
24
+ items: ReportItem[];
25
+ /** Screenshots the scout saved during the run. From the scout, `file` names a file in its output folder; once
26
+ * stored, the runner adds the bytes' sha256 and the evidence row that holds them. */
27
+ images: ReportImage[];
28
+ };
29
+ export type ReportItem = {
30
+ title: string;
31
+ why: string;
32
+ url: string;
33
+ image: string | null;
34
+ };
35
+ export type ReportImage = {
36
+ file: string;
37
+ caption: string;
38
+ url: string;
39
+ sha256?: string;
40
+ artifact?: number;
23
41
  };
24
42
  export type ReportParseResult = {
25
43
  ok: true;
@@ -38,7 +56,15 @@ export declare const REPORT_LIMITS: {
38
56
  readonly followUps: 5;
39
57
  readonly followUpTitle: 200;
40
58
  readonly followUpGoal: 2000;
59
+ readonly items: 6;
60
+ readonly itemTitle: 200;
61
+ readonly itemWhy: 1000;
62
+ readonly url: 2000;
63
+ readonly images: 8;
64
+ readonly caption: 300;
41
65
  };
66
+ /** A screenshot's name in the scout's output folder: one plain file name, PNG or JPEG, never a path. */
67
+ export declare const REPORT_IMAGE_FILE: RegExp;
42
68
  /**
43
69
  * The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
44
70
  * mode only lets a session write its own plan file, so a Claude scout
@@ -82,6 +108,47 @@ export declare const SCOUT_OUTPUT_JSON_SCHEMA: {
82
108
  readonly additionalProperties: false;
83
109
  };
84
110
  };
111
+ readonly items: {
112
+ readonly type: "array";
113
+ readonly items: {
114
+ readonly type: "object";
115
+ readonly properties: {
116
+ readonly title: {
117
+ readonly type: "string";
118
+ };
119
+ readonly why: {
120
+ readonly type: "string";
121
+ };
122
+ readonly url: {
123
+ readonly type: "string";
124
+ };
125
+ readonly image: {
126
+ readonly type: "string";
127
+ };
128
+ };
129
+ readonly required: readonly ["title", "why", "url"];
130
+ readonly additionalProperties: false;
131
+ };
132
+ };
133
+ readonly images: {
134
+ readonly type: "array";
135
+ readonly items: {
136
+ readonly type: "object";
137
+ readonly properties: {
138
+ readonly file: {
139
+ readonly type: "string";
140
+ };
141
+ readonly caption: {
142
+ readonly type: "string";
143
+ };
144
+ readonly url: {
145
+ readonly type: "string";
146
+ };
147
+ };
148
+ readonly required: readonly ["file", "caption", "url"];
149
+ readonly additionalProperties: false;
150
+ };
151
+ };
85
152
  };
86
153
  readonly required: readonly ["title", "summary", "report"];
87
154
  readonly additionalProperties: false;