toolroll 0.9.4 → 0.9.5

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.
@@ -384,6 +384,16 @@ export type BrowserResultPanel = {
384
384
  } | null;
385
385
  reviewHistory: string | null;
386
386
  attention: string[];
387
+ /** Requirements only a person can confirm, in plain words, and the one
388
+ * Accept that records the decision (null when it is not offered here). */
389
+ youCheck: {
390
+ lines: string[];
391
+ accept: {
392
+ action: string;
393
+ run: number;
394
+ returnTo: string;
395
+ } | null;
396
+ } | null;
387
397
  /** Storage limits on saved output (shortened logs or diffs): shown on request. */
388
398
  limits: string[];
389
399
  tabs: {
package/dist/cli.js CHANGED
@@ -1225,7 +1225,7 @@ async function runDemoCommand(argv, write) {
1225
1225
  const DEMO_USAGE = "`toolroll demo [--port <n>] [--host <addr>] [--allow-host name:port,…] [--keep]` — the port is a number under 65536; absent, a free one is picked";
1226
1226
  // Asking how it works never starts a sandbox.
1227
1227
  if (argv.includes("--help") || argv.includes("-h")) {
1228
- write(json ? envelopeJson({ ok: true, command: "demo", usage: DEMO_USAGE }) : `${DEMO_USAGE}\n\nA throwaway sandbox with a seeded fleet and flows mid-flight. It never spends and never reaches outside; Ctrl-C removes it unless --keep.`);
1228
+ write(json ? envelopeJson({ ok: true, command: "demo", usage: DEMO_USAGE }) : `${DEMO_USAGE}\n\nA throwaway sandbox: a scripted lead in Chat, sample projects and flows mid-flight. It never calls a model, spends or reaches outside; Ctrl-C removes it unless --keep.`);
1229
1229
  return 0;
1230
1230
  }
1231
1231
  if (!Number.isInteger(port) || port < 0 || port > 65_535) {
@@ -1248,9 +1248,9 @@ async function runDemoCommand(argv, write) {
1248
1248
  const { createDemoSandbox } = await import("./demo.js");
1249
1249
  const { createDecisionServer } = await import("./serve.js");
1250
1250
  const now = new Date();
1251
- const { sandbox, store, seed, evidenceRoot, passwordFile } = createDemoSandbox(now);
1251
+ const { sandbox, store, seed, evidenceRoot, passwordFile, lead } = createDemoSandbox(now);
1252
1252
  const server = createDecisionServer({
1253
- store, evidenceRoot, clock: () => new Date(), repos: seed.repos,
1253
+ store, evidenceRoot, clock: () => new Date(), repos: seed.repos, demoLead: lead,
1254
1254
  ...(allowedHosts.length === 0 ? {} : { allowedHosts }),
1255
1255
  });
1256
1256
  await new Promise((ready, failed) => {
@@ -1268,14 +1268,13 @@ async function runDemoCommand(argv, write) {
1268
1268
  write(envelopeJson({ ok: true, command: "demo", url, sandbox, login: { name: seed.login.name, passwordFile }, keep }));
1269
1269
  }
1270
1270
  else {
1271
- write("Demo sandbox is up — seeded fleet, zero spend, zero remotes.");
1271
+ write("Demo sandbox is up. A scripted lead answers Chat: no model, no key, no spend, nothing leaves this machine.");
1272
1272
  write("");
1273
1273
  write(` open ${url}`);
1274
1274
  write(` login ${seed.login.name} / ${seed.login.password}`);
1275
1275
  write(` sandbox ${sandbox}`);
1276
1276
  write("");
1277
- write("Two projects are seeded. The inbox works project-free; the board and");
1278
- write("routines ask you to open one first — pick payments-api.");
1277
+ write("Open Chat and ask for a change, like \"fix the flaky refund test\".");
1279
1278
  write("");
1280
1279
  write(keep
1281
1280
  ? "Ctrl-C stops the server; the sandbox is KEPT. It stays a demo forever — every spending command refuses it."
@@ -1290,6 +1289,7 @@ async function runDemoCommand(argv, write) {
1290
1289
  process.on("SIGINT", stop);
1291
1290
  process.on("SIGTERM", stop);
1292
1291
  });
1292
+ lead.stop();
1293
1293
  await new Promise(closed => server.close(() => closed()));
1294
1294
  store.close();
1295
1295
  if (!keep) {
@@ -103,6 +103,11 @@ export type EffectiveContainment = {
103
103
  export declare function effectiveContainment(policy: ContainmentPolicy, capability: ContainmentCapability): EffectiveContainment;
104
104
  /** One-line status words for `daemon status`, `up`, and the certificate. */
105
105
  export declare function describeContainment(effective: EffectiveContainment): string;
106
+ /** The one sentence a person reads at start when agents on Linux run
107
+ * without native containment that the machine could offer once set up;
108
+ * null when there is nothing a person needs to read. The exact
109
+ * `describeContainment` line stays in --verbose and in a service log. */
110
+ export declare function containmentNotice(effective: EffectiveContainment): string | null;
106
111
  /** The status envelope's shape: no paths a person did not already know, no secrets. */
107
112
  export declare function containmentStatus(effective: EffectiveContainment): Record<string, unknown>;
108
113
  /**
@@ -191,6 +191,15 @@ export function describeContainment(effective) {
191
191
  return `containment: observed (preferred native, ${backend} unavailable) — ${effective.capability.detail}`;
192
192
  return `containment: observed (${effective.policy}) — ${effective.capability.detail}`;
193
193
  }
194
+ /** The one sentence a person reads at start when agents on Linux run
195
+ * without native containment that the machine could offer once set up;
196
+ * null when there is nothing a person needs to read. The exact
197
+ * `describeContainment` line stays in --verbose and in a service log. */
198
+ export function containmentNotice(effective) {
199
+ if (effective.refusal !== null || effective.mode !== "observed" || effective.capability.platform !== "linux" || effective.capability.available)
200
+ return null;
201
+ return "Agents run without Linux process containment on this machine (it needs a delegated cgroup). That's fine for trying Toolroll; see docs/guide/linux.md to turn it on.";
202
+ }
194
203
  /** The status envelope's shape: no paths a person did not already know, no secrets. */
195
204
  export function containmentStatus(effective) {
196
205
  return {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Chat in `toolroll demo`: the scripted lead's conversation (src/demo.ts),
3
+ * rendered as plain server HTML with native forms. One small script keeps a
4
+ * running build's progress live; without it the page still works by reload.
5
+ */
6
+ import type { DemoExchange } from "./demo.js";
7
+ /** What a finished exchange's stored result holds, read back from its evidence. */
8
+ export type DemoResultView = {
9
+ diff: string | null;
10
+ checkLog: string | null;
11
+ checks: {
12
+ status: string;
13
+ detail: string;
14
+ };
15
+ screenshot: {
16
+ href: string;
17
+ caption: string;
18
+ } | null;
19
+ additions: number;
20
+ deletions: number;
21
+ files: number;
22
+ taskHref: string;
23
+ };
24
+ /** The conversation itself; the live script swaps this region in place. */
25
+ export declare function demoThreadHtml(exchanges: readonly DemoExchange[], csrf: string, resultOf: (exchange: DemoExchange) => DemoResultView | null): string;
26
+ export declare function demoChatHtml(input: {
27
+ exchanges: readonly DemoExchange[];
28
+ csrf: string;
29
+ version: number;
30
+ problem: string | null;
31
+ resultOf: (exchange: DemoExchange) => DemoResultView | null;
32
+ }): string;
33
+ /**
34
+ * Keeps a running build live: polls while one is working and swaps the
35
+ * thread in place, unless the visitor is typing inside it. Enter sends.
36
+ */
37
+ export declare const DEMO_CHAT_SCRIPT = "(() => {\n const thread = () => document.getElementById(\"demo-thread\");\n let timer = 0;\n const editing = region => region.contains(document.activeElement) && document.activeElement.matches(\"textarea, input\") || region.querySelector(\"details[open] textarea:not(:placeholder-shown)\") !== null;\n const tick = async () => {\n const region = thread();\n if (!region) { timer = setTimeout(tick, 400); return; }\n if (region.dataset.working !== \"1\") return;\n try {\n const answer = await fetch(\"/chat/demo/live\", { headers: { accept: \"application/json\" }, credentials: \"same-origin\" });\n if (answer.ok) {\n const next = await answer.json();\n if (String(next.version) !== region.dataset.version && !editing(region)) {\n region.innerHTML = next.html;\n region.dataset.version = String(next.version);\n region.dataset.working = next.working ? \"1\" : \"0\";\n if (!next.working) {\n const last = region.querySelector(\".demo-turn:last-of-type .demo-result\");\n if (last) last.scrollIntoView({ block: \"nearest\", behavior: matchMedia(\"(prefers-reduced-motion: reduce)\").matches ? \"auto\" : \"smooth\" });\n }\n }\n }\n } catch { /* the next tick tries again */ }\n if (thread()?.dataset.working === \"1\") timer = setTimeout(tick, 700);\n };\n const start = () => { clearTimeout(timer); tick(); };\n document.addEventListener(\"keydown\", event => {\n const field = event.target;\n if (event.key !== \"Enter\" || event.shiftKey || event.isComposing || !(field instanceof HTMLTextAreaElement) || field.id !== \"demo-message\") return;\n if (field.value.trim() === \"\") return;\n event.preventDefault();\n field.form?.requestSubmit();\n });\n window.addEventListener(\"standing-orders:workspace-rendered\", start);\n if (document.readyState === \"loading\") document.addEventListener(\"DOMContentLoaded\", start); else start();\n})();";
@@ -0,0 +1,150 @@
1
+ const SUGGESTIONS = ["Fix the flaky refund test", "Rewrite the empty Payouts page", "Put the new invoice view behind a flag"];
2
+ const esc = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3
+ const hidden = (csrf) => `<input type="hidden" name="csrf" value="${esc(csrf)}">`;
4
+ function hint(csrf) {
5
+ return `<section class="demo-hint" aria-label="Get started">` +
6
+ `<p class="demo-hint-title">Ask for something, like “fix the flaky refund test”</p>` +
7
+ `<form method="post" action="/chat/demo/ask" class="demo-suggestions">${hidden(csrf)}` +
8
+ SUGGESTIONS.map(one => `<button type="submit" name="message" value="${esc(one)}">${esc(one)}</button>`).join("") +
9
+ `</form></section>`;
10
+ }
11
+ function planCard(exchange, csrf) {
12
+ const plan = exchange.plan;
13
+ const goal = exchange.note === null ? plan.goal : `${plan.goal} Also: ${exchange.note}`;
14
+ const actions = exchange.state === "proposed"
15
+ ? `<div class="demo-actions">` +
16
+ `<form method="post" action="/chat/demo/${exchange.id}/approve" class="approve-form">${hidden(csrf)}<button type="submit">Approve</button></form>` +
17
+ `<details class="demo-more"><summary>Change it</summary>` +
18
+ `<form method="post" action="/chat/demo/${exchange.id}/change">${hidden(csrf)}` +
19
+ `<label for="demo-change-${exchange.id}">What should change?</label>` +
20
+ `<textarea id="demo-change-${exchange.id}" name="note" rows="2" maxlength="500" required></textarea>` +
21
+ `<button type="submit">Update plan</button></form></details></div>`
22
+ : exchange.state === "replaced"
23
+ ? `<p class="meta">Replaced by the updated plan below.</p>`
24
+ : `<p class="meta">Approved.</p>`;
25
+ return `<section class="card demo-plan" aria-label="Plan">` +
26
+ `<p class="demo-kicker">Plan · ${esc(plan.project)}</p>` +
27
+ `<h2>${esc(plan.title)}</h2>` +
28
+ `<p>${esc(goal)}</p>` +
29
+ `<h3>Boundaries</h3><ul>${plan.boundaries.map(one => `<li>${esc(one)}</li>`).join("")}</ul>` +
30
+ `<h3>Checks</h3><ul>${plan.checks.map(one => `<li>${esc(one)}</li>`).join("")}</ul>` +
31
+ actions + `</section>`;
32
+ }
33
+ function buildCard(exchange) {
34
+ const stages = [["planning", "Planning"], ["building", "Building"], ["checking", "Running checks"]];
35
+ const at = stages.findIndex(([key]) => key === exchange.stage);
36
+ return `<section class="card demo-build" id="demo-${exchange.id}-work" aria-label="Build" aria-busy="true">` +
37
+ `<p class="demo-kicker">${esc(exchange.plan.project)}</p>` +
38
+ `<h2>${esc(stages[Math.max(0, at)][1])}…</h2>` +
39
+ `<ol class="demo-steps">${stages.map(([, label], index) => `<li class="${index < at ? "done" : index === at ? "now" : ""}"${index === at ? ` aria-current="step"` : ""}>${esc(label)}</li>`).join("")}</ol>` +
40
+ (exchange.progress === null ? "" : `<p class="meta demo-progress">${esc(exchange.progress)}</p>`) +
41
+ `</section>`;
42
+ }
43
+ function diffHtml(diff) {
44
+ return diff.replace(/\n$/, "").split("\n").map(line => {
45
+ const kind = line.startsWith("diff --git") || line.startsWith("new file") ? "file"
46
+ : line.startsWith("+++") || line.startsWith("---") ? "meta"
47
+ : line.startsWith("@@") ? "hunk"
48
+ : line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx";
49
+ return `<span class="demo-diff-${kind}">${esc(line) || " "}</span>`;
50
+ }).join("");
51
+ }
52
+ function resultCard(exchange, csrf, result) {
53
+ if (result === null)
54
+ return `<section class="card demo-result"><p class="meta">This result's saved evidence is unavailable.</p></section>`;
55
+ const status = exchange.state === "complete" ? `<span class="badge demo-complete">Complete</span>`
56
+ : exchange.state === "sent-back" ? `<span class="badge">Sent back</span>` : `<span class="badge demo-ready">Ready</span>`;
57
+ const passed = result.checks.status === "passed";
58
+ const summary = `${result.files} file${result.files === 1 ? "" : "s"} changed · +${result.additions} −${result.deletions} · ` +
59
+ `<span class="${passed ? "demo-pass" : "demo-fail"}">${esc(result.checks.detail)}</span>`;
60
+ const actions = exchange.state === "ready"
61
+ ? `<div class="demo-actions">` +
62
+ `<form method="post" action="/chat/demo/${exchange.id}/complete">${hidden(csrf)}<button type="submit" class="demo-primary">Complete</button></form>` +
63
+ `<details class="demo-more"><summary>Request changes</summary>` +
64
+ `<form method="post" action="/chat/demo/${exchange.id}/revise">${hidden(csrf)}` +
65
+ `<label for="demo-revise-${exchange.id}">What should change?</label>` +
66
+ `<textarea id="demo-revise-${exchange.id}" name="note" rows="2" maxlength="500" required></textarea>` +
67
+ `<button type="submit">Send back</button></form></details></div>`
68
+ : `<p class="meta"><a href="${esc(result.taskHref)}">Open in Tasks</a></p>`;
69
+ return `<section class="card demo-result" id="demo-${exchange.id}-work" aria-label="Result">` +
70
+ `<p class="demo-state">${status}<span class="meta">${esc(exchange.plan.project)}</span></p>` +
71
+ `<h2>${esc(exchange.plan.title)}</h2>` +
72
+ `<p>${esc(exchange.plan.conclusion)}</p>` +
73
+ `<p class="meta">${summary}</p>` +
74
+ actions +
75
+ (result.diff === null ? "" : `<details class="demo-evidence"${exchange.state === "ready" ? " open" : ""}><summary>Changes</summary><pre class="demo-diff">${diffHtml(result.diff)}</pre></details>`) +
76
+ (result.checkLog === null ? "" : `<details class="demo-evidence"><summary>Check log</summary><pre class="demo-log">${esc(result.checkLog)}</pre></details>`) +
77
+ (result.screenshot === null ? "" : `<details class="demo-evidence"><summary>Screenshot</summary><figure><img src="${esc(result.screenshot.href)}" alt="${esc(result.screenshot.caption)}" width="960" height="600"><figcaption class="meta">${esc(result.screenshot.caption)}</figcaption></figure></details>`) +
78
+ `</section>`;
79
+ }
80
+ /** The conversation itself; the live script swaps this region in place. */
81
+ export function demoThreadHtml(exchanges, csrf, resultOf) {
82
+ if (exchanges.length === 0)
83
+ return hint(csrf);
84
+ return exchanges.map(exchange => {
85
+ const parts = [
86
+ `<div class="demo-said demo-you"><p>${esc(exchange.asked)}</p></div>`,
87
+ `<div class="demo-said demo-lead"><p class="demo-who">Lead</p><p>${esc(exchange.reply)}</p></div>`,
88
+ planCard(exchange, csrf),
89
+ ];
90
+ if (exchange.state === "working")
91
+ parts.push(buildCard(exchange));
92
+ if (exchange.state === "ready" || exchange.state === "complete" || exchange.state === "sent-back")
93
+ parts.push(resultCard(exchange, csrf, resultOf(exchange)));
94
+ if (exchange.state === "complete") {
95
+ parts.push(`<div class="demo-said demo-lead"><p class="demo-who">Lead</p><p>Done. That's the whole loop: ask, approve, Ready, Complete. Ask for something else whenever you like.</p></div>`);
96
+ }
97
+ return `<article class="demo-turn" id="demo-${exchange.id}">${parts.join("")}</article>`;
98
+ }).join("");
99
+ }
100
+ export function demoChatHtml(input) {
101
+ const working = input.exchanges.some(one => one.state === "working");
102
+ return `<div class="demo-chat">` +
103
+ (input.problem === null ? "" : `<p class="problem" role="alert">${esc(input.problem)}</p>`) +
104
+ `<div id="demo-thread" class="demo-thread" aria-live="polite" data-version="${input.version}" data-working="${working ? "1" : "0"}">` +
105
+ demoThreadHtml(input.exchanges, input.csrf, input.resultOf) + `</div>` +
106
+ `<form method="post" action="/chat/demo/ask" class="demo-composer">${hidden(input.csrf)}` +
107
+ `<label for="demo-message" class="so-sr-only">Message the lead</label>` +
108
+ `<textarea id="demo-message" name="message" rows="2" maxlength="500" placeholder="Ask the lead for a change" required></textarea>` +
109
+ `<button type="submit" class="demo-primary">Send</button></form></div>`;
110
+ }
111
+ /**
112
+ * Keeps a running build live: polls while one is working and swaps the
113
+ * thread in place, unless the visitor is typing inside it. Enter sends.
114
+ */
115
+ export const DEMO_CHAT_SCRIPT = `(() => {
116
+ const thread = () => document.getElementById("demo-thread");
117
+ let timer = 0;
118
+ const editing = region => region.contains(document.activeElement) && document.activeElement.matches("textarea, input") || region.querySelector("details[open] textarea:not(:placeholder-shown)") !== null;
119
+ const tick = async () => {
120
+ const region = thread();
121
+ if (!region) { timer = setTimeout(tick, 400); return; }
122
+ if (region.dataset.working !== "1") return;
123
+ try {
124
+ const answer = await fetch("/chat/demo/live", { headers: { accept: "application/json" }, credentials: "same-origin" });
125
+ if (answer.ok) {
126
+ const next = await answer.json();
127
+ if (String(next.version) !== region.dataset.version && !editing(region)) {
128
+ region.innerHTML = next.html;
129
+ region.dataset.version = String(next.version);
130
+ region.dataset.working = next.working ? "1" : "0";
131
+ if (!next.working) {
132
+ const last = region.querySelector(".demo-turn:last-of-type .demo-result");
133
+ if (last) last.scrollIntoView({ block: "nearest", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
134
+ }
135
+ }
136
+ }
137
+ } catch { /* the next tick tries again */ }
138
+ if (thread()?.dataset.working === "1") timer = setTimeout(tick, 700);
139
+ };
140
+ const start = () => { clearTimeout(timer); tick(); };
141
+ document.addEventListener("keydown", event => {
142
+ const field = event.target;
143
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing || !(field instanceof HTMLTextAreaElement) || field.id !== "demo-message") return;
144
+ if (field.value.trim() === "") return;
145
+ event.preventDefault();
146
+ field.form?.requestSubmit();
147
+ });
148
+ window.addEventListener("standing-orders:workspace-rendered", start);
149
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
150
+ })();`;
package/dist/demo.d.ts CHANGED
@@ -52,4 +52,102 @@ export declare function createDemoSandbox(now: Date): {
52
52
  seed: DemoSeed;
53
53
  evidenceRoot: string;
54
54
  passwordFile: string;
55
+ lead: DemoLead;
55
56
  };
57
+ type DemoProject = "payments-api" | "web-console";
58
+ type DemoFile = {
59
+ path: string;
60
+ before: string | null;
61
+ after: string;
62
+ };
63
+ export type DemoPlan = {
64
+ kind: "flaky-test" | "bug" | "copy" | "flag" | "default";
65
+ project: DemoProject;
66
+ title: string;
67
+ goal: string;
68
+ boundaries: string[];
69
+ checks: string[];
70
+ /** What the builder reports while it works, one line per beat. */
71
+ progress: string[];
72
+ conclusion: string;
73
+ checkOutput: string;
74
+ screenshot: {
75
+ caption: string;
76
+ accent: readonly [number, number, number];
77
+ };
78
+ files: DemoFile[];
79
+ };
80
+ /** The scripted lead's choice: a plan named by the request's words, else the default. */
81
+ export declare function pickDemoPlan(text: string): DemoPlan;
82
+ export type DemoExchange = {
83
+ id: number;
84
+ /** What the visitor typed, or the note that asked for this plan. */
85
+ asked: string;
86
+ reply: string;
87
+ plan: DemoPlan;
88
+ /** A visitor's extra instruction carried into the plan by Change it or Request changes. */
89
+ note: string | null;
90
+ state: "proposed" | "replaced" | "working" | "ready" | "complete" | "sent-back";
91
+ stage: "planning" | "building" | "checking" | null;
92
+ progress: string | null;
93
+ taskId: string | null;
94
+ runId: number | null;
95
+ approvedAt: number | null;
96
+ };
97
+ export type DemoLead = {
98
+ exchanges(): readonly DemoExchange[];
99
+ /** Changes whenever anything a reader would see changes. */
100
+ version(): number;
101
+ ask(text: string, now: Date): DemoExchange;
102
+ approve(id: number, now: Date): {
103
+ ok: true;
104
+ } | {
105
+ ok: false;
106
+ message: string;
107
+ };
108
+ change(id: number, note: string, now: Date): {
109
+ ok: true;
110
+ } | {
111
+ ok: false;
112
+ message: string;
113
+ };
114
+ requestChanges(id: number, note: string, now: Date): {
115
+ ok: true;
116
+ } | {
117
+ ok: false;
118
+ message: string;
119
+ };
120
+ /** Complete through the real completion act, then mark the exchange. */
121
+ complete(id: number, now: Date, act: (taskId: string, runId: number) => {
122
+ ok: true;
123
+ } | {
124
+ ok: false;
125
+ message: string;
126
+ }): {
127
+ ok: true;
128
+ } | {
129
+ ok: false;
130
+ message: string;
131
+ };
132
+ advance(now: Date): void;
133
+ stop(): void;
134
+ };
135
+ /**
136
+ * The scripted lead. `stepMs` is one beat: planning takes one, building two
137
+ * (one per progress line), checks one, so a build lands Ready in four beats.
138
+ * State advances on a timer AND whenever someone reads, so a page and the
139
+ * Tasks list agree without anyone polling.
140
+ */
141
+ export declare function createDemoLead(input: {
142
+ store: Store;
143
+ repos: {
144
+ api: string;
145
+ web: string;
146
+ };
147
+ evidenceRoot: string;
148
+ approver: string;
149
+ token: string;
150
+ stepMs?: number;
151
+ clock?: () => Date;
152
+ }): DemoLead;
153
+ export {};