toolroll 0.9.73 → 0.9.75

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 (145) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-turns.js +5 -0
  4. package/dist/agents-live.d.ts +31 -0
  5. package/dist/agents-live.js +43 -0
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +31 -30
  11. package/dist/browser-workspace.d.ts +7 -1
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-acceptance.d.ts +4 -1
  14. package/dist/chat-acceptance.js +13 -3
  15. package/dist/chat-continuity.js +10 -0
  16. package/dist/chat-quiet.js +3 -3
  17. package/dist/cli.js +1 -1
  18. package/dist/coding-handoff.js +4 -3
  19. package/dist/coding-ui.js +2 -2
  20. package/dist/coding-workspace.js +16 -4
  21. package/dist/contracts/console-api.d.ts +5 -1
  22. package/dist/contracts/console-api.js +10 -0
  23. package/dist/control-ui.d.ts +3 -2
  24. package/dist/control-ui.js +7 -2
  25. package/dist/demo.js +5 -3
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +4 -1
  28. package/dist/desktop-update.js +43 -9
  29. package/dist/dispose.js +35 -2
  30. package/dist/exec.js +13 -16
  31. package/dist/first-run.d.ts +20 -1
  32. package/dist/first-run.js +71 -0
  33. package/dist/flow-card-task.js +3 -2
  34. package/dist/flow-unattended.d.ts +2 -0
  35. package/dist/flow-unattended.js +12 -1
  36. package/dist/integrations.d.ts +4 -3
  37. package/dist/integrations.js +5 -6
  38. package/dist/invoke.d.ts +13 -0
  39. package/dist/invoke.js +21 -12
  40. package/dist/knowledge-ui.d.ts +4 -1
  41. package/dist/knowledge-ui.js +10 -5
  42. package/dist/lead-contract.d.ts +1 -1
  43. package/dist/lead-contract.js +2 -2
  44. package/dist/lead-follow.js +3 -1
  45. package/dist/lead-progress.d.ts +4 -1
  46. package/dist/lead-progress.js +3 -1
  47. package/dist/lead.js +4 -0
  48. package/dist/live-bus.d.ts +2 -0
  49. package/dist/live-bus.js +1 -0
  50. package/dist/live-children-read.d.ts +52 -0
  51. package/dist/live-children-read.js +333 -0
  52. package/dist/live-children.d.ts +73 -0
  53. package/dist/live-children.js +318 -0
  54. package/dist/live-detail.d.ts +4 -3
  55. package/dist/live-detail.js +9 -5
  56. package/dist/live-input.js +7 -8
  57. package/dist/live-main-read.d.ts +19 -0
  58. package/dist/live-main-read.js +91 -0
  59. package/dist/live-open.d.ts +28 -0
  60. package/dist/live-open.js +125 -0
  61. package/dist/live-records.d.ts +10 -1
  62. package/dist/live-records.js +16 -5
  63. package/dist/live.d.ts +54 -22
  64. package/dist/live.js +218 -69
  65. package/dist/openrouter-models.d.ts +5 -2
  66. package/dist/openrouter-models.js +7 -4
  67. package/dist/operate.js +5 -2
  68. package/dist/peek-cli.d.ts +10 -3
  69. package/dist/peek-cli.js +17 -8
  70. package/dist/planner.js +1 -1
  71. package/dist/project-learning.js +44 -6
  72. package/dist/project-tools.js +19 -1
  73. package/dist/provider-limits.d.ts +3 -1
  74. package/dist/provider-limits.js +5 -2
  75. package/dist/provider.js +6 -12
  76. package/dist/pull-request-ui.d.ts +1 -1
  77. package/dist/pull-request-ui.js +5 -1
  78. package/dist/push.d.ts +2 -0
  79. package/dist/push.js +24 -19
  80. package/dist/read-only-request.d.ts +4 -0
  81. package/dist/read-only-request.js +15 -0
  82. package/dist/release-adapters.js +4 -0
  83. package/dist/release.d.ts +3 -0
  84. package/dist/release.js +3 -0
  85. package/dist/repository-context-ui.js +2 -1
  86. package/dist/repository-context.js +6 -1
  87. package/dist/result-follow-ups.js +20 -1
  88. package/dist/result-review.js +5 -1
  89. package/dist/run-efficiency.d.ts +3 -3
  90. package/dist/run-efficiency.js +20 -8
  91. package/dist/scout.d.ts +3 -0
  92. package/dist/scout.js +5 -3
  93. package/dist/serve.js +196 -29
  94. package/dist/server/agents-live-filed.d.ts +17 -0
  95. package/dist/server/agents-live-filed.js +97 -0
  96. package/dist/server/agents-live-lead.d.ts +47 -0
  97. package/dist/server/agents-live-lead.js +116 -0
  98. package/dist/server/agents-live-room.d.ts +23 -0
  99. package/dist/server/agents-live-room.js +91 -0
  100. package/dist/server/chat.js +115 -53
  101. package/dist/server/chrome.js +2 -2
  102. package/dist/server/console.css +68 -40
  103. package/dist/server/http.d.ts +5 -0
  104. package/dist/server/last-checks.d.ts +17 -0
  105. package/dist/server/last-checks.js +22 -0
  106. package/dist/server/live.d.ts +4 -0
  107. package/dist/server/live.js +42 -2
  108. package/dist/server/pages.js +98 -4
  109. package/dist/server/people-tokens.js +7 -4
  110. package/dist/server/render-chat.d.ts +15 -1
  111. package/dist/server/render-chat.js +14 -7
  112. package/dist/server/render-pages.d.ts +10 -5
  113. package/dist/server/render-pages.js +83 -74
  114. package/dist/server/render-results.d.ts +11 -2
  115. package/dist/server/render-results.js +54 -12
  116. package/dist/server/render-settings.js +3 -3
  117. package/dist/server/render-tasks.js +13 -3
  118. package/dist/server/route-table.js +19 -2
  119. package/dist/server/runtime.d.ts +17 -2
  120. package/dist/server/settings.js +85 -32
  121. package/dist/server/task-views.js +1 -2
  122. package/dist/server/tasks.js +70 -43
  123. package/dist/storage-ui.d.ts +2 -0
  124. package/dist/storage-ui.js +1 -1
  125. package/dist/storage.js +2 -1
  126. package/dist/store.d.ts +34 -1
  127. package/dist/store.js +108 -11
  128. package/dist/stream-scope.d.ts +18 -0
  129. package/dist/stream-scope.js +28 -0
  130. package/dist/subagent-work.js +3 -0
  131. package/dist/subscription-chat.js +5 -3
  132. package/dist/task-status.d.ts +28 -1
  133. package/dist/task-status.js +93 -11
  134. package/dist/teardown-git.d.ts +27 -1
  135. package/dist/teardown-git.js +185 -39
  136. package/dist/telegram-progress.js +4 -4
  137. package/dist/telegram-status.js +8 -3
  138. package/dist/tool-secret-store.js +8 -0
  139. package/dist/toolroll-update-ui.d.ts +2 -0
  140. package/dist/toolroll-update-ui.js +4 -3
  141. package/dist/toolroll-update.js +10 -9
  142. package/dist/work-index.js +29 -12
  143. package/dist/workspace-ui.d.ts +5 -0
  144. package/dist/workspace-ui.js +38 -10
  145. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  /** Optional, project-local advice. Core review and approval never depend on it. */
2
+ import { inReadOnlyRequest } from "./read-only-request.js";
2
3
  import { createHash } from 'node:crypto';
3
4
  import { execFileSync } from 'node:child_process';
4
- import { realpathSync, statSync } from 'node:fs';
5
- import { resolve } from 'node:path';
5
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
6
7
  import { readVerifiedArtifact, scanForSecrets } from './evidence.js';
7
8
  import { normalizeStructuredJson } from "./structured-output.js";
8
9
  import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
@@ -50,7 +51,8 @@ const git = (repo, args, seen) => {
50
51
  const known = seen?.get(key);
51
52
  if (known !== undefined)
52
53
  return known;
53
- const out = execFileSync('git', ['--no-optional-locks', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
54
+ // Bounded, and no hook or fsmonitor program: it also runs from the console's background pass.
55
+ const out = execFileSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
54
56
  seen?.set(key, out);
55
57
  return out;
56
58
  };
@@ -62,9 +64,42 @@ export const legacyIdentityOf = (repo, common, st) => learningSha(JSON.stringify
62
64
  function identityFacts(repo, seen) {
63
65
  if (canonicalProject(repo) !== repo)
64
66
  throw new Error('Project identity changed.');
65
- const common = realpathSync(resolve(repo, git(repo, ['rev-parse', '--git-common-dir'], seen)));
67
+ // A page visit starts no git (docs/plans/get-side-effects.md): it reads the same answer from the folder itself.
68
+ const common = realpathSync(resolve(repo, inReadOnlyRequest() ? commonDirOnDisk(repo) : git(repo, ['rev-parse', '--git-common-dir'], seen)));
66
69
  return { common, st: statSync(common) };
67
70
  }
71
+ /** The shared Git folder as git finds it for a checkout: `.git` itself, or the folder a `.git` file names, through its
72
+ * `commondir` when it is a linked worktree. Throws when the folder holds neither. */
73
+ function commonDirOnDisk(repo) {
74
+ const dotGit = join(repo, '.git');
75
+ if (lstatSync(dotGit).isDirectory())
76
+ return dotGit;
77
+ const named = /^gitdir: (.+)$/m.exec(smallFile(dotGit))?.[1]?.trim();
78
+ if (!named)
79
+ throw new Error('Project identity is unavailable.');
80
+ const gitDir = resolve(repo, named);
81
+ if (!lstatSync(gitDir).isDirectory())
82
+ throw new Error('Project identity is unavailable.');
83
+ const commondir = join(gitDir, 'commondir');
84
+ if (!existsSync(commondir))
85
+ return gitDir;
86
+ return resolve(gitDir, smallFile(commondir).trim());
87
+ }
88
+ /** A small plain file's text, read through one descriptor that follows no link and never waits (a FIFO, a device or a
89
+ * file swapped in between is refused, never read), at most 4 KiB. */
90
+ function smallFile(path) {
91
+ const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK);
92
+ try {
93
+ const st = fstatSync(fd);
94
+ if (!st.isFile() || st.size > 4096)
95
+ throw new Error('Project identity is unavailable.');
96
+ const buffer = Buffer.alloc(4096);
97
+ return buffer.subarray(0, readSync(fd, buffer, 0, 4096, 0)).toString('utf8');
98
+ }
99
+ finally {
100
+ closeSync(fd);
101
+ }
102
+ }
68
103
  export function learningIdentity(repo, seen) {
69
104
  const { common, st } = identityFacts(repo, seen);
70
105
  return identityOf(repo, common, st);
@@ -114,7 +149,8 @@ export function reconcileIdentity(store, repo, seen, now = new Date()) {
114
149
  if (!done)
115
150
  reconciled.set(store, done = new Set());
116
151
  const key = `${repo}\n${legacy}`;
117
- if (legacy !== current && !done.has(key)) {
152
+ // Carrying saved rows to the current identity is a write: never a page visit's (the background pass does it).
153
+ if (legacy !== current && !done.has(key) && !inReadOnlyRequest()) {
118
154
  if (!store.handle.prepare('SELECT 1 FROM project_identity_carry WHERE repo=? AND from_identity=? AND to_identity=?').get(repo, legacy, current)
119
155
  && store.handle.prepare(STORED_IDENTITY).get(repo, legacy)) {
120
156
  // Damaged knowledge history keeps its own error; retry on the next read.
@@ -413,7 +449,9 @@ function supported(store, root, lesson, seen) {
413
449
  }
414
450
  export function learningView(store, root, repo, actor, before = 0) {
415
451
  admission(store, repo, actor);
416
- recoverLearning(store, root, repo);
452
+ // Recovering learning from reviewed runs writes: the console's background pass and each run's start do it.
453
+ if (!inReadOnlyRequest())
454
+ recoverLearning(store, root, repo);
417
455
  const identity = learningIdentity(repo), accepted = acceptedIdentities(store, repo);
418
456
  const p = store.handle.prepare('SELECT * FROM learning_policy WHERE repo=?').get(repo);
419
457
  const revision = Number(p?.['revision'] ?? 0), enabled = accepted.has(String(p?.['identity'])) && p?.['enabled'] === 1;
@@ -19,6 +19,7 @@
19
19
  * the agent. A tool connected by signing in gives a run only an access token
20
20
  * of its own (oauth-run-token.ts).
21
21
  */
22
+ import { inReadOnlyRequest } from "./read-only-request.js";
22
23
  import { toolRefusal } from "./policy.js";
23
24
  import { createHash } from "node:crypto";
24
25
  import { spawn } from "node:child_process";
@@ -319,6 +320,23 @@ function moveIn(backend, folder, repo, tool) {
319
320
  export function readToolSecrets(repo, tool, state) {
320
321
  const folder = secretFolder(state);
321
322
  const backend = secretBackend();
323
+ // A page visit moves nothing (docs/plans/get-side-effects.md): it reads the store, else the file not yet moved in.
324
+ if (inReadOnlyRequest()) {
325
+ const stored = readStored(backend, folder, repo, tool);
326
+ if (stored !== null)
327
+ return stored;
328
+ for (const file of [legacyFile(folder, repo, tool), backend === null ? null : toolFile(folder, repo, tool, ".json")]) {
329
+ if (file === null || !existsSync(file))
330
+ continue;
331
+ try {
332
+ const values = readSecretFile(file);
333
+ if (values !== null)
334
+ return values;
335
+ }
336
+ catch { /* kept as it is, and never used */ }
337
+ }
338
+ return {};
339
+ }
322
340
  moveIn(backend, folder, repo, tool);
323
341
  return readStored(backend, folder, repo, tool) ?? {};
324
342
  }
@@ -1067,7 +1085,7 @@ export function secretsSetFor(repo, tool, state) {
1067
1085
  if (names === null) {
1068
1086
  const values = readToolSecrets(repo, tool.name, state);
1069
1087
  names = Object.keys(values).filter(name => values[name] !== "");
1070
- if (backend !== null)
1088
+ if (backend !== null && !inReadOnlyRequest())
1071
1089
  writeNames(folder, repo, tool.name, names);
1072
1090
  }
1073
1091
  return tool.secrets.filter(one => names.includes(one.name)).map(one => one.name);
@@ -24,7 +24,9 @@ export type LimitReading = {
24
24
  billing?: Billing;
25
25
  };
26
26
  export declare const record: (value: unknown) => Record<string, unknown> | null;
27
- /** A Claude stream event's limits, or null when it isn't a `rate_limit_event` or says nothing usable. */
27
+ /** A Claude stream event's limits, or null when it isn't the session's own `rate_limit_event` or says nothing usable.
28
+ * A subagent's (parent_tool_use_id set) is never read: the session reports its own windows each turn, and a
29
+ * subagent-tagged reading can't be told apart from one shaped by the subagent's own traffic (stream-scope.ts). */
28
30
  export declare function claudeLimitsOf(event: unknown): LimitReading | null;
29
31
  /** Codex's `account/rateLimits/read` answer as a reading, or null. */
30
32
  export declare function codexLimitsOf(result: unknown): LimitReading | null;
@@ -1,3 +1,4 @@
1
+ import { topLevelEvent } from "./stream-scope.js";
1
2
  const WINDOW_NAME = /^[a-z][a-z0-9_]{0,39}$/;
2
3
  const CLAUDE_MINUTES = { five_hour: 300, seven_day: 10_080, seven_day_opus: 10_080, seven_day_sonnet: 10_080 };
3
4
  const finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -8,10 +9,12 @@ const epoch = (value) => {
8
9
  return seconds === null || seconds < 1_000_000_000 || seconds > 10_000_000_000 ? null : new Date(seconds * 1000).toISOString();
9
10
  };
10
11
  export const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
11
- /** A Claude stream event's limits, or null when it isn't a `rate_limit_event` or says nothing usable. */
12
+ /** A Claude stream event's limits, or null when it isn't the session's own `rate_limit_event` or says nothing usable.
13
+ * A subagent's (parent_tool_use_id set) is never read: the session reports its own windows each turn, and a
14
+ * subagent-tagged reading can't be told apart from one shaped by the subagent's own traffic (stream-scope.ts). */
12
15
  export function claudeLimitsOf(event) {
13
16
  const body = record(event);
14
- if (body === null || body["type"] !== "rate_limit_event")
17
+ if (body === null || body["type"] !== "rate_limit_event" || !topLevelEvent(body))
15
18
  return null;
16
19
  const info = record(body["rate_limit_info"]);
17
20
  if (info === null)
package/dist/provider.js CHANGED
@@ -29,6 +29,7 @@ import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
29
29
  import { ACP_AGENTS, isAcpProvider, withAcp } from "./acp-agents.js";
30
30
  import { acpParse } from "./acp-client.js";
31
31
  import { agentSpecSchema, MODEL_ID, modelIdSchema, PROVIDER_ID_VALUES, providerIdSchema } from "./contracts/provider.js";
32
+ import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
32
33
  export const PROVIDER_IDS = PROVIDER_ID_VALUES;
33
34
  export function isProviderId(value) {
34
35
  return providerIdSchema.safeParse(value).success;
@@ -292,17 +293,6 @@ function claudeFinalMessage(result) {
292
293
  }
293
294
  return typeof result.result === "string" ? result.result : null;
294
295
  }
295
- /**
296
- * The primary-result allowlist (arc 1 finding 10): only an absent origin or
297
- * an explicit human origin can be the main query's accounting envelope.
298
- * Every other kind — task-notification, channel, peer, coordinator, and
299
- * anything the SDK grows later — fails closed as a non-primary result.
300
- */
301
- function claudePrimaryOrigin(event) {
302
- if (event.origin === undefined || event.origin === null)
303
- return true;
304
- return typeof event.origin === "object" && String(event.origin["kind"] ?? "") === "human";
305
- }
306
296
  function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConflict = false) {
307
297
  const input = result?.usage?.input_tokens;
308
298
  const output = result?.usage?.output_tokens;
@@ -378,6 +368,10 @@ function claudeParse(stdout) {
378
368
  }
379
369
  if (event === null || typeof event !== "object")
380
370
  continue;
371
+ // A subagent's object (parent_tool_use_id set) is never the session's envelope, init, result or overflow witness
372
+ // (stream-scope.ts). The transport's own witness carries no parent and still counts.
373
+ if (!topLevelEvent(event))
374
+ continue;
381
375
  const type = event["type"];
382
376
  if (type === undefined) {
383
377
  // Legacy buffered envelope: one JSON object, no event framing.
@@ -404,7 +398,7 @@ function claudeParse(stdout) {
404
398
  // replacement for the primary result's usage/accounting envelope.
405
399
  transportFailure = result;
406
400
  }
407
- if (primary === null && claudePrimaryOrigin(result))
401
+ if (primary === null && primaryResultEvent(event))
408
402
  primary = result;
409
403
  }
410
404
  }
@@ -16,7 +16,7 @@ export type PullRequestSettingsView = {
16
16
  } | {
17
17
  ok: false;
18
18
  message: string;
19
- } | null;
19
+ } | "unchecked" | null;
20
20
  said: string | null;
21
21
  problem: string | null;
22
22
  };
@@ -22,8 +22,12 @@ export function pullRequestSettingsHtml(view) {
22
22
  const check = view.check;
23
23
  if (check === null)
24
24
  return html `${head}<section class="card pr-settings"><p><strong>Off</strong></p><p class="meta">An approver can turn on pull requests.</p></section>`;
25
+ // The checks ask git and GitHub, so they run when asked, never on opening the page (docs/plans/get-side-effects.md).
26
+ const checkButton = (label) => postForm("/settings/pull-requests/check", html `<button type="submit"${label === "Check again" ? html ` class="secondary"` : ""}>${label}</button>`, { hidden: { repo: view.repo } });
27
+ if (check === "unchecked")
28
+ return html `${head}<section class="card pr-settings"><p><strong>Off</strong></p><p class="meta">Check this project's GitHub setup to see what turning on would do.</p>${checkButton("Check setup")}</section>`;
25
29
  if (!check.ok) {
26
- return html `${head}<section class="card pr-settings"><p><strong>Can’t turn on yet</strong></p><p>${check.message}</p><p><a class="button-link" href="/settings/pull-requests?repo=${encodeURIComponent(view.repo)}">Check again</a></p></section>`;
30
+ return html `${head}<section class="card pr-settings"><p><strong>Can’t turn on yet</strong></p><p>${check.message}</p>${checkButton("Check again")}</section>`;
27
31
  }
28
32
  const plan = check.plan;
29
33
  return html `${head}${post("on", html `<p><strong>Ready to turn on</strong></p><ul class="pr-terms"><li>Pull requests open on <span class="mono">${plan.githubRepo}</span> into <span class="mono">${plan.base}</span>${plan.account === null ? "" : `, as ${plan.account}`}.</li><li>Complete offers “Complete and open a pull request”, from the exact completed commit. A flow's Pull request zone can open one too.</li><li>Merging squashes and deletes the branch. A person merges with their password; a flow merges only when its zone says so, after a person approved the card.</li></ul>${password}<button type="submit">Turn on pull requests</button>`, { github: plan.githubRepo, base: plan.base })}`;
package/dist/push.d.ts CHANGED
@@ -31,6 +31,8 @@ export type VapidKeys = {
31
31
  fingerprint: string;
32
32
  };
33
33
  export declare function vapidFingerprint(publicKey: string): string;
34
+ /** Load the saved pair only (null when none is saved yet): what a page reads (docs/plans/get-side-effects.md). */
35
+ export declare function loadVapidKeys(dir: string): VapidKeys | null;
34
36
  /**
35
37
  * Mint-or-load, atomically across processes (finding 29): exclusive
36
38
  * creation; the loser of a race re-reads the winner's file; and a loaded
package/dist/push.js CHANGED
@@ -35,6 +35,10 @@ const fromB64url = (text) => Buffer.from(text, "base64url");
35
35
  export function vapidFingerprint(publicKey) {
36
36
  return createHash("sha256").update(publicKey, "utf8").digest("hex").slice(0, 16);
37
37
  }
38
+ /** Load the saved pair only (null when none is saved yet): what a page reads (docs/plans/get-side-effects.md). */
39
+ export function loadVapidKeys(dir) {
40
+ return readVapidKeys(join(dir, VAPID_FILE));
41
+ }
38
42
  /**
39
43
  * Mint-or-load, atomically across processes (finding 29): exclusive
40
44
  * creation; the loser of a race re-reads the winner's file; and a loaded
@@ -43,25 +47,7 @@ export function vapidFingerprint(publicKey) {
43
47
  */
44
48
  export function loadOrCreateVapidKeys(dir) {
45
49
  const path = join(dir, VAPID_FILE);
46
- const read = () => {
47
- let raw;
48
- try {
49
- raw = readFileSync(path, "utf8");
50
- }
51
- catch {
52
- return null;
53
- }
54
- const parsed = JSON.parse(raw);
55
- if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") {
56
- throw new Error(`${VAPID_FILE} is not a key file`);
57
- }
58
- const check = createECDH("prime256v1");
59
- check.setPrivateKey(fromB64url(parsed.privateKey));
60
- if (b64url(check.getPublicKey()) !== parsed.publicKey) {
61
- throw new Error(`${VAPID_FILE} holds a mismatched key pair — refusing to sign with it`);
62
- }
63
- return { publicKey: parsed.publicKey, privateKey: parsed.privateKey, fingerprint: vapidFingerprint(parsed.publicKey) };
64
- };
50
+ const read = () => readVapidKeys(path);
65
51
  const existing = read();
66
52
  if (existing !== null)
67
53
  return existing;
@@ -87,6 +73,25 @@ export function loadOrCreateVapidKeys(dir) {
87
73
  return winner;
88
74
  }
89
75
  }
76
+ function readVapidKeys(path) {
77
+ let raw;
78
+ try {
79
+ raw = readFileSync(path, "utf8");
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ const parsed = JSON.parse(raw);
85
+ if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") {
86
+ throw new Error(`${VAPID_FILE} is not a key file`);
87
+ }
88
+ const check = createECDH("prime256v1");
89
+ check.setPrivateKey(fromB64url(parsed.privateKey));
90
+ if (b64url(check.getPublicKey()) !== parsed.publicKey) {
91
+ throw new Error(`${VAPID_FILE} holds a mismatched key pair — refusing to sign with it`);
92
+ }
93
+ return { publicKey: parsed.publicKey, privateKey: parsed.privateKey, fingerprint: vapidFingerprint(parsed.publicKey) };
94
+ }
90
95
  // ---- RFC 8291 encryption ----------------------------------------------------
91
96
  /**
92
97
  * The deterministic core, exported for the Appendix A vector test: given
@@ -0,0 +1,4 @@
1
+ /** Run a GET or HEAD request's work. */
2
+ export declare function asReadOnlyRequest<T>(work: () => T): T;
3
+ /** Whether the current work answers a GET or HEAD request. */
4
+ export declare function inReadOnlyRequest(): boolean;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A page visit only reads (docs/plans/get-side-effects.md). The server runs every GET and HEAD inside this scope, and
3
+ * the few store reads that used to tidy up as they went (closing an operating mode past its expiry) check it and
4
+ * leave the tidying to the background pass and the paths that act.
5
+ */
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ const scope = new AsyncLocalStorage();
8
+ /** Run a GET or HEAD request's work. */
9
+ export function asReadOnlyRequest(work) {
10
+ return scope.run(true, work);
11
+ }
12
+ /** Whether the current work answers a GET or HEAD request. */
13
+ export function inReadOnlyRequest() {
14
+ return scope.getStore() === true;
15
+ }
@@ -115,6 +115,9 @@ export function gateStateOf(answer, accounts = []) {
115
115
  const failed = newest !== undefined && newest["finishedAt"] != null && newest["outcome"] !== "built" && newest["outcome"] !== "no-change" && Number(newest["id"]) !== resultRun
116
116
  ? `run ${String(newest["id"])} ended ${String(newest["outcome"] ?? "without an outcome")}` : null;
117
117
  const worktree = runs.find(run => run["id"] === resultRun)?.["worktree"];
118
+ // Queued again or still building past the result shown (a regate): that result is the one before the rerun.
119
+ const state = object(answer["task"])["state"];
120
+ const pending = resultRun !== null && (state === "queued" || state === "running" || (newest !== undefined && newest["finishedAt"] == null && Number(newest["id"]) > resultRun));
118
121
  return {
119
122
  exists: true,
120
123
  candidate: textOr(scope["candidate"]),
@@ -131,6 +134,7 @@ export function gateStateOf(answer, accounts = []) {
131
134
  : checks["status"] === "passed" && checks["level"] !== "full" ? "not-full" : String(checks["status"] ?? "not-run"),
132
135
  },
133
136
  failed,
137
+ pending,
134
138
  completion: textOr(completion["digest"]) === null ? null : { actor: String(completion["actor"]), at: String(completion["at"]), digest: String(completion["digest"]) },
135
139
  };
136
140
  }
package/dist/release.d.ts CHANGED
@@ -90,6 +90,9 @@ export type GateState = {
90
90
  } | null;
91
91
  /** A finished build that left no result to complete (it failed before the check), in words. */
92
92
  failed: string | null;
93
+ /** A newer attempt is queued or running (`task regate` after a failed check): the result shown is the one before it.
94
+ * Absent from a plane too old to say. */
95
+ pending?: boolean;
93
96
  completion: {
94
97
  actor: string;
95
98
  at: string;
package/dist/release.js CHANGED
@@ -535,6 +535,9 @@ export async function runRelease(options, adapters) {
535
535
  await watch("dispatch");
536
536
  const gate = await adapters.toolroll.gate(j.task.id);
537
537
  await approved(gate);
538
+ // A rerun of the same approved commit is on its way: the failure before it is not this release's answer.
539
+ if (gate.pending === true && gate.result?.check !== "passed")
540
+ return null;
538
541
  if (gate.failed !== null)
539
542
  throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`);
540
543
  if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
@@ -1,5 +1,6 @@
1
1
  import { html, postForm } from "./html.js";
2
+ import { framedSection } from './knowledge-ui.js';
2
3
  export function repositoryContextHtml(repo, query, result, csrf) {
3
4
  const root = `/settings/knowledge?repo=${encodeURIComponent(repo)}`;
4
- return html `<section class="knowledge repository-context"><h2>Relevant code</h2><form method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Find code<input name="q" value="${query}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>${result === null ? '' : html `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>${result.excerpts.length ? result.excerpts.map(one => html `<details><summary>${one.file}:${one.line}${one.symbol ? ` · ${one.symbol}` : ''}</summary><p class="meta">${one.reason}</p><pre>${one.text}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`) : html `<p>No matching source was found in this bounded search.</p>`}${result.relationships.length ? html `<details><summary>Related files</summary><ul>${result.relationships.map(one => html `<li>${one.from} → ${one.to} · ${one.kind}</li>`)}</ul></details>` : ''}<details><summary>Context details</summary><p>${result.index.status}${result.checkout ? ` · checkout ${result.checkout.head.slice(0, 8)}` : ''}</p>${result.warnings.map(one => html `<p>${one}</p>`)}</details>`}${csrf ? html `<details><summary>Refresh code index</summary>${postForm("/settings/knowledge/refresh", html `<p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button>`, { hidden: { repo } })}</details>` : ''}</section>`;
5
+ return framedSection('Relevant code', html `<form class="field-row" method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Find code<input name="q" value="${query}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>${result === null ? '' : html `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>${result.excerpts.length ? result.excerpts.map(one => html `<details><summary>${one.file}:${one.line}${one.symbol ? ` · ${one.symbol}` : ''}</summary><p class="meta">${one.reason}</p><pre>${one.text}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`) : html `<p>No matching source was found in this bounded search.</p>`}${result.relationships.length ? html `<details><summary>Related files</summary><ul>${result.relationships.map(one => html `<li>${one.from} → ${one.to} · ${one.kind}</li>`)}</ul></details>` : ''}<details><summary>Context details</summary><p>${result.index.status}${result.checkout ? ` · checkout ${result.checkout.head.slice(0, 8)}` : ''}</p>${result.warnings.map(one => html `<p>${one}</p>`)}</details>`}${csrf ? html `<details><summary>Refresh code index</summary>${postForm("/settings/knowledge/refresh", html `<p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button>`, { hidden: { repo } })}</details>` : ''}`, 'repository-context');
5
6
  }
@@ -1,6 +1,8 @@
1
1
  /** Optional repository context. Callers admit project access; this module grants no authority.
2
2
  * The index is disposable. Every read hashes the current bounded tracked source set;
3
3
  * a missing/changed/unreadable index falls back to ordinary text search, never a task gate. */
4
+ import { inReadOnlyRequest } from './read-only-request.js';
5
+ import { checkoutGitSync } from './teardown-git.js';
4
6
  import { createHash, randomUUID } from 'node:crypto';
5
7
  import { execFileSync } from 'node:child_process';
6
8
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -12,7 +14,10 @@ const CODE = /\.(?:[cm]?[jt]sx?)$/i;
12
14
  const TEXT = /\.(?:[cm]?[jt]sx?|md|txt|css|html|json)$/i;
13
15
  const MAX_NODES = 16_000, MAX_EDGES = 12_000;
14
16
  const sha = (value) => createHash('sha256').update(value).digest('hex');
15
- const git = (repo, args) => execFileSync('git', ['--no-optional-locks', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2_000_000, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
17
+ // Read-only and bounded: no index refresh, no hooks, no fsmonitor program.
18
+ // On a page read (Settings -> Knowledge's search), the hardened checkout git: no filter, hook, program or network.
19
+ const git = (repo, args) => inReadOnlyRequest() ? checkoutGitSync(repo, args, { timeoutMs: 10_000, maxBuffer: 2_000_000 }).trimEnd()
20
+ : execFileSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2_000_000, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
16
21
  const inside = (root, path) => { const r = relative(root, path); return r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
17
22
  function capture(request) {
18
23
  const repo = realpathSync(request.repo);
@@ -46,6 +46,25 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
46
46
  catch { /* an unreadable receipt upgrades nothing */ }
47
47
  }
48
48
  }
49
+ // A pass stands only while its saved log still verifies and the command it ran is still the approved one, the same
50
+ // bar the build's own check receipt meets (verification-evidence.ts): otherwise it can't be read as a pass now.
51
+ const repo = root === undefined ? null : repoOfRun(store, runId);
52
+ const logVerifies = (artifactId) => {
53
+ if (root === undefined || artifactId === null)
54
+ return false;
55
+ const artifact = store.getArtifact(artifactId);
56
+ return artifact !== null && artifact.run === runId && artifact.kind === "structured-output" && artifact.capture === FOLLOW_UP_LOG && artifact.captureStatus !== "failed" && readVerifiedArtifact(root, artifact).ok;
57
+ };
58
+ const stillApproved = (level, digest) => {
59
+ if (repo === null)
60
+ return false;
61
+ try {
62
+ return checkCommandFor(store, repo, level).command?.digest === digest;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ };
49
68
  return rows.filter(row => row.action === CHECKS_REQUESTED && (row.outcome === "quick" || row.outcome === "full")).map(request => {
50
69
  const [head = "", why = "person", digest = "", recordedBase = ""] = (request.detail ?? "").split(" · ");
51
70
  const started = rows.find(row => row.action === CHECKS_STARTED && row.outcome === String(request.id)) ?? null;
@@ -59,7 +78,7 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
59
78
  // With the evidence at hand, only a sealed receipt can say passed: for this result's own commit, and,
60
79
  // for a batch, a batch that names this result.
61
80
  if (status === "passed" && root !== undefined && (sealed === null || !sealed.ran || sealed.exitCode !== 0 || (sealed.head !== null && sealed.head !== head)
62
- || (base.why === "batch" && sealed.batch === null))) {
81
+ || (base.why === "batch" && sealed.batch === null) || !logVerifies(sealed.log) || !stillApproved(base.level, digest))) {
63
82
  return { ...base, state: "not-run", note: "The saved check result could not be verified." };
64
83
  }
65
84
  const exit = /exit (\d+)/.exec(finished.detail ?? "");
@@ -28,7 +28,11 @@ export function evidenceResultStatusOf(result, publication, evidence) {
28
28
  stored = {
29
29
  token: "evidence-damaged",
30
30
  label: "Result saved, but some saved material is unavailable",
31
- detail: `Some saved material is missing, damaged, or could not be read. The machine's verdict at completion is unchanged: ${result.verdict === "verified" ? "the approved check passed against it then" : recorded.label.toLowerCase()}.`,
31
+ // Never "passed" when the check itself can't be read now (task-status.ts approvedCheckOf).
32
+ detail: recorded.token === "check-unreadable" ? `Some saved material is missing, damaged, or could not be read. ${recorded.detail}`
33
+ : `Some saved material is missing, damaged, or could not be read. ${result.verdict === "verified" && result.checks !== undefined && ["ready-to-review", "pr-opened", "merge-observed", "pr-closed"].includes(recorded.token)
34
+ ? "The machine's verdict at completion is unchanged: the approved check passed against it then."
35
+ : result.verdict === "verified" ? "Open the result to see its checks." : `The machine's verdict at completion is unchanged: ${recorded.label.toLowerCase()}.`}`,
32
36
  tone: "problem",
33
37
  action: { label: "See what is missing", kind: "open-result" },
34
38
  };
@@ -8,7 +8,7 @@
8
8
  * kept 24 hours) are read once, bounded, through the descriptor-safe live reader, for step counts and observed step
9
9
  * times. No command text is ever stored here: repeats are counted, never quoted.
10
10
  */
11
- import type { Run, Store } from "./store.js";
11
+ import { type Run, type Store } from "./store.js";
12
12
  import type { LiveActivityKind, LiveRecord } from "./live-records.js";
13
13
  export declare const RUN_EFFICIENCY_VERSION = 3;
14
14
  /** Budgets for one derivation: never more than the live file's own cap, and a bounded record count. */
@@ -62,10 +62,10 @@ export declare function usageOf(run: Pick<Run, "provider" | "model" | "tokensIn"
62
62
  * others their times are partly made up (Codex reports a step when it completes, ACP agents may report late), so no
63
63
  * duration is claimed for them. */
64
64
  export declare function stepsOf(records: Iterable<LiveRecord>, truncated: string | null, measured?: boolean): RunEfficiency["steps"];
65
- /** Derive a finished run's How it ran from its records. */
65
+ /** Derive a finished run's How it ran from its records, read under the run's own captured "Show commands" answer. */
66
66
  export declare function deriveRunEfficiency(run: Run & {
67
67
  usageJson?: string | null;
68
- }, evidenceRoot: string, now: Date): RunEfficiency;
68
+ }, evidenceRoot: string, now: Date, commands?: boolean): RunEfficiency;
69
69
  /**
70
70
  * A finished run's How it ran: the saved one when it was derived by this version, else derived now and saved (once:
71
71
  * a row whose steps were complete or have expired is final). An unfinished run is derived but never saved.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * How a run went (docs/plans/run-efficiency.md): derived from the run's own records, display only. It never changes a
3
+ * completion, an approval, a charge or a check verdict, and it claims nothing its sources don't hold — an unreported
4
+ * number is unknown (null, shown as a dash), never zero.
5
+ *
6
+ * Sources, and what each can say: the run row (wall times, tokens, the provider's raw usage with its cache counts, the
7
+ * agent's turns where reported) is kept with the run; the live records (`<evidence>/live/<run>.jsonl`, display-only,
8
+ * kept 24 hours) are read once, bounded, through the descriptor-safe live reader, for step counts and observed step
9
+ * times. No command text is ever stored here: repeats are counted, never quoted.
10
+ */
11
+ import { runLiveCommands } from "./store.js";
1
12
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
13
  import { join } from "node:path";
3
14
  import { LIVE_RETAIN_MS, legacyLiveLogPath, liveLogPath, liveWindowRecords } from "./live.js";
@@ -95,7 +106,7 @@ const noSteps = (state, reason) => ({ state, reason, total: null, failed: null,
95
106
  * directory), measured once, and never more than the budget read whatever the file does meanwhile; then parsed line by
96
107
  * line. A file over the budget, in the older text format, with lines that don't read, or cut or capped while the agent
97
108
  * ran, says its steps are incomplete. Null when there is no step file at all. */
98
- function readRecords(evidenceRoot, runId) {
109
+ function readRecords(evidenceRoot, runId, commands) {
99
110
  try {
100
111
  const dir = lstatSync(join(evidenceRoot, "live"));
101
112
  if (!dir.isDirectory() || dir.isSymbolicLink())
@@ -154,10 +165,10 @@ function readRecords(evidenceRoot, runId) {
154
165
  whole.length = EFFICIENCY_RECORDS;
155
166
  const keys = { inKey: false };
156
167
  // One record per line: a whole line that doesn't read is a record lost.
157
- const read = liveWindowRecords({ text: whole.length === 0 ? "" : `${whole.join("\n")}\n`, format: "records" }, keys);
168
+ const read = liveWindowRecords({ text: whole.length === 0 ? "" : `${whole.join("\n")}\n`, format: "records" }, commands, keys);
158
169
  const lost = whole.length - read.length;
159
170
  // A finished run's last line without its newline is read like any other; one that doesn't read is a record lost.
160
- const tail = unfinished.trim() === "" ? [] : liveWindowRecords({ text: `${unfinished}\n`, format: "records" }, keys);
171
+ const tail = unfinished.trim() === "" ? [] : liveWindowRecords({ text: `${unfinished}\n`, format: "records" }, commands, keys);
161
172
  const records = [...read, ...tail];
162
173
  const lostAll = lost + (unfinished.trim() !== "" && tail.length === 0 ? 1 : 0);
163
174
  if (records.length === 0 && text.trim() !== "")
@@ -170,10 +181,10 @@ function readRecords(evidenceRoot, runId) {
170
181
  : null;
171
182
  return { records, truncated };
172
183
  }
173
- /** Derive a finished run's How it ran from its records. */
174
- export function deriveRunEfficiency(run, evidenceRoot, now) {
184
+ /** Derive a finished run's How it ran from its records, read under the run's own captured "Show commands" answer. */
185
+ export function deriveRunEfficiency(run, evidenceRoot, now, commands = false) {
175
186
  const finished = run.finishedAt;
176
- const read = readRecords(evidenceRoot, run.id);
187
+ const read = readRecords(evidenceRoot, run.id, commands);
177
188
  const steps = read !== null && "unreadable" in read ? noSteps("truncated", read.unreadable)
178
189
  : read !== null ? stepsOf(read.records, read.truncated, run.provider === "claude")
179
190
  : finished !== null && now.getTime() - Date.parse(finished) > LIVE_RETAIN_MS ? noSteps("expired", "Step details are kept for a day after a run; this one's have expired.")
@@ -196,8 +207,9 @@ export function runEfficiencyOf(store, evidenceRoot, runId, now) {
196
207
  return null;
197
208
  const usageJson = store.handle.prepare("SELECT usage_json FROM run WHERE id = ?").get(runId)?.["usage_json"];
198
209
  const full = { ...run, usageJson: typeof usageJson === "string" ? usageJson : null };
210
+ const commands = runLiveCommands(store, runId);
199
211
  if (run.finishedAt === null)
200
- return deriveRunEfficiency(full, evidenceRoot, now);
212
+ return deriveRunEfficiency(full, evidenceRoot, now, commands);
201
213
  let saved;
202
214
  try {
203
215
  saved = store.handle.prepare("SELECT parser_version, payload_json FROM run_efficiency WHERE run = ?").get(runId);
@@ -211,7 +223,7 @@ export function runEfficiencyOf(store, evidenceRoot, runId, now) {
211
223
  }
212
224
  catch { /* derived again below */ }
213
225
  }
214
- const derived = deriveRunEfficiency(full, evidenceRoot, now);
226
+ const derived = deriveRunEfficiency(full, evidenceRoot, now, commands);
215
227
  const payload = JSON.stringify(derived);
216
228
  // A row too large for its column (it can't be: the counts are bounded) is shown, never saved.
217
229
  // Saving is a convenience: a store that can't take the row (read-only, busy) still shows what was derived.
package/dist/scout.d.ts CHANGED
@@ -131,6 +131,9 @@ export declare function readReportImage(folder: string, file: string): {
131
131
  };
132
132
  /** The shorten turn's ask: the fields over their limits, and the same report again within them. */
133
133
  export declare function shortenReportBrief(over: readonly ReportProblem[], reportFile: string, structured: boolean): string;
134
+ /** Whether a stream event shows the session writing its plan-mode plan
135
+ * file (~/.claude/plans/…) or handing a plan to ExitPlanMode. */
136
+ export declare function touchesPlanFile(event: Record<string, unknown>): boolean;
134
137
  /** A cited link without its secrets: credential-shaped query values and path parts become REDACTED, a fragment
135
138
  * that carries one is dropped, and a link still showing one keeps only its origin. Unchanged when it has none. */
136
139
  export declare function scrubUrl(text: string): {
package/dist/scout.js CHANGED
@@ -37,6 +37,7 @@ import { grantOf, researchToolsOf } from "./mcp-connect.js";
37
37
  import { startScoutProxy } from "./scout-net.js";
38
38
  import * as browserCheck from "./scout-browser.js";
39
39
  import { invokeAgent } from "./invoke.js";
40
+ import { topLevelEvent } from "./stream-scope.js";
40
41
  const GIT = "git";
41
42
  const AGENT_ENV_DENYLIST = [...TELEGRAM_TOKEN_ENVS];
42
43
  const DEFAULT_SCOUT_TIMEOUT_MS = 20 * 60_000;
@@ -298,7 +299,7 @@ async function scoutWith(store, request, outputDir) {
298
299
  }
299
300
  // The live window (peek): the same transcript file the builder keeps,
300
301
  // so `toolroll peek` and the run page can watch this session too.
301
- const liveLog = openLiveLog(root, request.runId, liveLogContext(store, taskRef, worktree));
302
+ const liveLog = openLiveLog(root, request.runId, liveLogContext(store, request.runId, taskRef, worktree, clock()));
302
303
  // Claude's report rides the terminal result event (run 2334's fix): plan
303
304
  // mode only lets the session write its own plan file. Codex has no
304
305
  // structured output and keeps the mailbox file.
@@ -706,8 +707,9 @@ export function shortenReportBrief(over, reportFile, structured) {
706
707
  }
707
708
  /** Whether a stream event shows the session writing its plan-mode plan
708
709
  * file (~/.claude/plans/…) or handing a plan to ExitPlanMode. */
709
- function touchesPlanFile(event) {
710
- if (event["type"] !== "assistant")
710
+ export function touchesPlanFile(event) {
711
+ // The session's own calls only: a subagent's (parent_tool_use_id set) is not the scout's plan (stream-scope.ts).
712
+ if (event["type"] !== "assistant" || !topLevelEvent(event))
711
713
  return false;
712
714
  const message = event["message"];
713
715
  if (typeof message !== "object" || message === null)