toolroll 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +38 -24
  2. package/dist/accent-colors.d.ts +6 -3
  3. package/dist/accent-colors.js +9 -4
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +23 -23
  6. package/dist/browser-workspace.d.ts +46 -0
  7. package/dist/builder.d.ts +18 -1
  8. package/dist/builder.js +177 -5
  9. package/dist/chat-delivery.js +6 -4
  10. package/dist/claim.d.ts +15 -1
  11. package/dist/claim.js +30 -2
  12. package/dist/control-ui.js +1 -1
  13. package/dist/daemon.d.ts +9 -0
  14. package/dist/daemon.js +21 -0
  15. package/dist/decision.d.ts +6 -0
  16. package/dist/decision.js +12 -0
  17. package/dist/desktop-update.d.ts +1 -1
  18. package/dist/dispatch.d.ts +3 -1
  19. package/dist/dispatch.js +13 -0
  20. package/dist/dispose.js +11 -7
  21. package/dist/exhaustion.d.ts +14 -0
  22. package/dist/exhaustion.js +35 -0
  23. package/dist/install-method.d.ts +17 -0
  24. package/dist/install-method.js +61 -0
  25. package/dist/invoke.js +26 -0
  26. package/dist/lead-status.d.ts +7 -0
  27. package/dist/lead-status.js +15 -2
  28. package/dist/mate.js +5 -2
  29. package/dist/operate.d.ts +5 -0
  30. package/dist/operate.js +109 -15
  31. package/dist/provider-auth.d.ts +138 -0
  32. package/dist/provider-auth.js +291 -0
  33. package/dist/provider.d.ts +2 -0
  34. package/dist/provider.js +8 -1
  35. package/dist/releases.d.ts +76 -0
  36. package/dist/releases.js +225 -0
  37. package/dist/serve.d.ts +4 -0
  38. package/dist/serve.js +207 -76
  39. package/dist/setup-guide.d.ts +2 -2
  40. package/dist/setup-guide.js +2 -0
  41. package/dist/skills.d.ts +6 -0
  42. package/dist/skills.js +8 -0
  43. package/dist/store.d.ts +2 -2
  44. package/dist/store.js +13 -4
  45. package/dist/subscription-chat.js +3 -0
  46. package/dist/update-notice.d.ts +5 -0
  47. package/dist/update-notice.js +4 -0
  48. package/dist/work-index.js +22 -4
  49. package/dist/worktree.d.ts +13 -0
  50. package/dist/worktree.js +55 -29
  51. package/package.json +1 -1
package/dist/dispose.js CHANGED
@@ -28,6 +28,7 @@ const TICK_FAILURE_REASONS = new Set([
28
28
  "agent",
29
29
  "agent-reported",
30
30
  "no-op",
31
+ "no-handoff",
31
32
  "moved-head",
32
33
  "moved-branch",
33
34
  "timeout",
@@ -49,6 +50,7 @@ const STANDALONE_BROKE_REASONS = new Set([
49
50
  "agent",
50
51
  "agent-reported",
51
52
  "no-op",
53
+ "no-handoff",
52
54
  "moved-head",
53
55
  "timeout",
54
56
  "git",
@@ -339,13 +341,15 @@ function disposeBuildOutcomeLocked(context, result) {
339
341
  // classification trusts only what the machine itself observed.
340
342
  const failureClass = result.reason === "agent-reported"
341
343
  ? "agent-reported"
342
- : result.reason === "no-op" || result.reason === "moved-head" || result.reason === "moved-branch"
343
- ? "no-op"
344
- : result.reason === "timeout" || result.reason === "git" || result.reason === "provider-init" || result.reason === "setup" || result.reason === "stopped" || result.reason === "provider-unattested" || result.reason === "provider-protocol"
345
- ? "retryable-infra"
346
- : result.reason === "commit-failure"
347
- ? "commit-failure"
348
- : "unknown";
344
+ : result.reason === "no-handoff"
345
+ ? "no-handoff"
346
+ : result.reason === "no-op" || result.reason === "moved-head" || result.reason === "moved-branch"
347
+ ? "no-op"
348
+ : result.reason === "timeout" || result.reason === "git" || result.reason === "provider-init" || result.reason === "setup" || result.reason === "stopped" || result.reason === "provider-unattested" || result.reason === "provider-protocol"
349
+ ? "retryable-infra"
350
+ : result.reason === "commit-failure"
351
+ ? "commit-failure"
352
+ : "unknown";
349
353
  // The attempt's ending, in words, on the run itself: the class is what
350
354
  // the strikes count, the message is what a person reads (an agent's
351
355
  // own verdict was already recorded by the builder under the same key).
@@ -20,6 +20,10 @@
20
20
  * class. Exit code, bare rate-limit prose, timeout, notFound, stderr,
21
21
  * and zero tokens NEVER independently establish exhaustion.
22
22
  * - Everything unmatched is `unknown` — the ordinary failure road.
23
+ * - A sign-in that no longer works (`auth-expired`) is recognized FIRST,
24
+ * for every provider and version: an expired or revoked login is not a
25
+ * property of a CLI version, and retrying it only burns attempts. It is
26
+ * never fallback-eligible — a lapsed login must not reach a paid key.
23
27
  */
24
28
  import type { ProviderId } from "./provider.js";
25
29
  export type TerminalClass =
@@ -29,6 +33,9 @@ export type TerminalClass =
29
33
  | "credits-depleted"
30
34
  /** Temporary throttling NOT tied to the usage cap — backoff, NEVER paid fallback. */
31
35
  | "transient-throttle"
36
+ /** The provider's sign-in or API key no longer works — NO retry, NO
37
+ * fallback: dispatch for that provider pauses until someone signs in. */
38
+ | "auth-expired"
32
39
  /** A definite non-exhaustion terminal (ordinary failure). */
33
40
  | "not-exhausted"
34
41
  /** No structural signal matched — the ordinary failure/strike road. */
@@ -82,4 +89,11 @@ export declare function classifyTerminal(args: {
82
89
  version: string | null;
83
90
  authMode: "subscription" | "api-key";
84
91
  terminal: StructuralTerminal | null;
92
+ /** What a run that failed within seconds, having produced nothing, said
93
+ * on its way out (stderr and unstructured stdout) — read ONLY for the
94
+ * sign-in signal: a CLI that is not logged in often exits before its
95
+ * structured stream begins. */
96
+ earlyExit?: string | null;
85
97
  }): TerminalClass;
98
+ /** Whether a provider's own words say its sign-in or key no longer works. */
99
+ export declare function isAuthFailure(text: string): boolean;
@@ -20,6 +20,10 @@
20
20
  * class. Exit code, bare rate-limit prose, timeout, notFound, stderr,
21
21
  * and zero tokens NEVER independently establish exhaustion.
22
22
  * - Everything unmatched is `unknown` — the ordinary failure road.
23
+ * - A sign-in that no longer works (`auth-expired`) is recognized FIRST,
24
+ * for every provider and version: an expired or revoked login is not a
25
+ * property of a CLI version, and retrying it only burns attempts. It is
26
+ * never fallback-eligible — a lapsed login must not reach a paid key.
23
27
  */
24
28
  /** The two classes that may authorize a fallback advance. */
25
29
  export const FALLBACK_ELIGIBLE = new Set([
@@ -111,6 +115,12 @@ export function classMatchesAuthMode(cls, authMode) {
111
115
  */
112
116
  export function classifyTerminal(args) {
113
117
  const { provider, version, authMode, terminal } = args;
118
+ // A sign-in that no longer works, before anything else and for any
119
+ // provider or version: no fixture is needed to know a login is gone.
120
+ if (terminal !== null && terminal.failed === true && isAuthFailure(`${terminal.code ?? ""}\n${terminal.text ?? ""}`))
121
+ return "auth-expired";
122
+ if (typeof args.earlyExit === "string" && isAuthFailure(args.earlyExit))
123
+ return "auth-expired";
114
124
  if (terminal === null)
115
125
  return "unknown";
116
126
  // The classifier's contract permits failed:false, but a non-failure
@@ -139,3 +149,28 @@ export function classifyTerminal(args) {
139
149
  // unknown above).
140
150
  return "not-exhausted";
141
151
  }
152
+ /**
153
+ * The sign-in signals, provider-neutral: Claude's expired or unrefreshable
154
+ * OAuth session and its "Please run /login", an API key the provider calls
155
+ * invalid or revoked, Codex's and Gemini's "not logged in", and an HTTP 401
156
+ * the harness reports as its terminal. Ordinary failures (a test that
157
+ * failed, a timeout, a usage limit) match none of these.
158
+ */
159
+ const AUTH_FAILURE = [
160
+ // Provider-specific phrasings only: a build's own output (a login feature's
161
+ // tests, an HTTP 401 in a log) must never read as the agent's sign-in.
162
+ /\bOAuth (?:session|token)\b[^\n]{0,80}\b(?:expired|could not be refreshed|revoked|invalid)/i,
163
+ /\bFailed to authenticate\b[^\n]{0,40}\b(?:OAuth|token|session|credentials|API key)/i,
164
+ /\bPlease run \/login\b/i,
165
+ /\binvalid[ _-](?:x-)?api[ _-]?key\b/i,
166
+ /\binvalid bearer token\b/i,
167
+ /\bapi[ _-]?key\b[^\n]{0,40}\b(?:is )?(?:invalid|revoked|expired|not valid)\b/i,
168
+ /"type"\s*:\s*"authentication_error"/i,
169
+ /\bMissing bearer (?:or basic )?authentication\b/i,
170
+ /^Not logged in\b/m,
171
+ ];
172
+ /** Whether a provider's own words say its sign-in or key no longer works. */
173
+ export function isAuthFailure(text) {
174
+ const bounded = text.slice(0, 8192);
175
+ return AUTH_FAILURE.some(re => re.test(bounded));
176
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * How this Toolroll was installed, read from where its bin really lives, and
3
+ * the one command that updates it that way. Only the path is read: nothing is
4
+ * run and nothing leaves the computer.
5
+ */
6
+ export type InstallKind = "npm" | "pnpm" | "bun" | "yarn" | "homebrew" | "npx" | "source";
7
+ export type InstallMethod = {
8
+ kind: InstallKind;
9
+ updateCommand: string;
10
+ };
11
+ export declare const UPDATE_COMMANDS: Record<InstallKind, string>;
12
+ export type InstallProbe = {
13
+ realpath?: (path: string) => string;
14
+ exists?: (path: string) => boolean;
15
+ };
16
+ /** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
17
+ export declare function installMethod(bin?: string | undefined, probe?: InstallProbe): InstallMethod;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * How this Toolroll was installed, read from where its bin really lives, and
3
+ * the one command that updates it that way. Only the path is read: nothing is
4
+ * run and nothing leaves the computer.
5
+ */
6
+ import { existsSync, realpathSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ export const UPDATE_COMMANDS = {
9
+ npm: "npm install -g toolroll@latest",
10
+ pnpm: "pnpm add -g toolroll@latest",
11
+ bun: "bun add -g toolroll@latest",
12
+ yarn: "yarn global add toolroll@latest",
13
+ homebrew: "brew upgrade ap9000/toolroll/toolroll",
14
+ npx: "npx toolroll@latest",
15
+ source: "git pull && npm install && npm run build",
16
+ };
17
+ /** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
18
+ export function installMethod(bin = process.argv[1], probe = {}) {
19
+ const kind = installKind(bin, probe);
20
+ return { kind, updateCommand: UPDATE_COMMANDS[kind] };
21
+ }
22
+ function installKind(bin, probe) {
23
+ if (bin === undefined || bin === "")
24
+ return "npm";
25
+ let real = bin;
26
+ try {
27
+ real = (probe.realpath ?? realpathSync)(bin);
28
+ }
29
+ catch {
30
+ // A bin that cannot be resolved is judged by the path it was run by.
31
+ }
32
+ const path = real.replace(/\\/g, "/");
33
+ // A plane deployed from a checkout by scripts/deploy-browser.mjs runs from staged-upgrades/browser-*: it updates
34
+ // by deploying from source, never from npm.
35
+ if (path.includes("/staged-upgrades/browser-"))
36
+ return "source";
37
+ if (/\/Cellar\/toolroll\//i.test(path))
38
+ return "homebrew";
39
+ if (path.includes("/_npx/"))
40
+ return "npx";
41
+ if (/\/\.bun\/install\/global\//.test(path))
42
+ return "bun";
43
+ if (/\/pnpm\/global\//i.test(path))
44
+ return "pnpm";
45
+ if (/\/(?:\.config\/yarn|yarn\/data)\/global\//i.test(path))
46
+ return "yarn";
47
+ if (path.includes("/node_modules/"))
48
+ return "npm";
49
+ // Not inside node_modules: a checkout, when the package's own folder is a git working copy.
50
+ const exists = probe.exists ?? existsSync;
51
+ let folder = dirname(real);
52
+ for (let depth = 0; depth < 4; depth++) {
53
+ if (exists(join(folder, "package.json")) && exists(join(folder, ".git")))
54
+ return "source";
55
+ const parent = dirname(folder);
56
+ if (parent === folder)
57
+ break;
58
+ folder = parent;
59
+ }
60
+ return "npm";
61
+ }
package/dist/invoke.js CHANGED
@@ -19,6 +19,7 @@
19
19
  import { adapterFor, auditOf } from "./provider.js";
20
20
  import { readProviderKey, readAuthModeStrict, PROVIDER_KEY_ENV, OWN_KEY_ENV } from "./keys.js";
21
21
  import { classifyTerminal } from "./exhaustion.js";
22
+ import { liftAuthPause } from "./provider-auth.js";
22
23
  import { attestProvider } from "./attest.js";
23
24
  import { run as runCommand, runOwnerTag, startClaudeHeldSession } from "./exec.js";
24
25
  import { currentContainment } from "./containment.js";
@@ -32,6 +33,9 @@ import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
32
33
  /** The claude binary name — kept for the legacy quota rows and tests that
33
34
  * describe history; new code carries a resolved AgentSpec instead. */
34
35
  export const PROVIDER_BINARY = "claude";
36
+ /** "Within seconds": how soon a silent failed run must end for its stderr to
37
+ * be read for the sign-in signal. */
38
+ const AUTH_EARLY_EXIT_MS = 30_000;
35
39
  /**
36
40
  * A provider can run the repository's own CLI. In a self-hosting build that
37
41
  * must never mean "open the database that launched me": a migration from the
@@ -306,6 +310,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
306
310
  ? await codexLoginBilling(attested !== null ? attested.executable : adapter.binary, [...(runOptions.omitEnv ?? []), ...adapter.extraOmitEnv, ...ownKeyEnv])
307
311
  : null;
308
312
  let result;
313
+ const spawnedAt = Date.now();
309
314
  try {
310
315
  // Under the stop watch (v52): the run's stop row is re-read while the
311
316
  // provider runs, and a stop kills THIS run's process group through the
@@ -438,11 +443,20 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
438
443
  // no version here yet (attested === null), so they classify fail-closed
439
444
  // until their exhaustion fixture — and the version proving it needs — is
440
445
  // captured and reviewed.
446
+ // A run that failed within seconds having produced nothing is read for
447
+ // the sign-in signal too: a CLI that is not logged in often says so on
448
+ // stderr before its structured stream starts.
449
+ const quietEarlyExit = result.code !== 0 && !result.timedOut && !result.notFound &&
450
+ Date.now() - spawnedAt <= AUTH_EARLY_EXIT_MS &&
451
+ envelope.promptConsumed !== true && (envelope.tokensOut ?? 0) === 0;
441
452
  const terminalClass = classifyTerminal({
442
453
  provider: spec.provider,
443
454
  version: attested === null ? null : attested.version,
444
455
  authMode,
445
456
  terminal: envelope.structuralTerminal,
457
+ // stderr and plain stdout only: a JSON event line can carry the agent's
458
+ // own command output (codex aggregated_output), never its sign-in.
459
+ earlyExit: quietEarlyExit ? `${result.stderr.slice(-4096)}\n${plainLines(result.stdout).slice(-4096)}` : null,
446
460
  });
447
461
  store.stampTerminalClass(runId, authMode, terminalClass);
448
462
  // Contradictory structural identity is a provider protocol failure, not
@@ -519,6 +533,14 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
519
533
  finalMessage: envelope.finalMessage,
520
534
  };
521
535
  }
536
+ // A run that worked proves its provider's sign-in works: a sign-in pause
537
+ // on it lifts, and its waiting tasks may start again.
538
+ if (result.code === 0 && envelope.structuralTerminal === null && envelope.promptConsumed !== false) {
539
+ try {
540
+ liftAuthPause(store, spec.provider, "run", `run #${runId}`, clock(), new Date(spawnedAt));
541
+ }
542
+ catch { /* the pause stays until the next proof */ }
543
+ }
522
544
  return {
523
545
  kind: "ran",
524
546
  outcome: {
@@ -778,3 +800,7 @@ async function codexLoginBilling(binary, omitEnv) {
778
800
  return null;
779
801
  }
780
802
  }
803
+ /** The lines of a harness's stdout that are not JSON events. */
804
+ function plainLines(stdout) {
805
+ return stdout.split("\n").filter(line => !line.trimStart().startsWith("{")).join("\n");
806
+ }
@@ -62,6 +62,13 @@ export type InstallationStatus = {
62
62
  plan: string | null;
63
63
  observedAt: string;
64
64
  })[];
65
+ /** Providers whose sign-in stopped working: their dispatch is paused. */
66
+ signIn: {
67
+ provider: string;
68
+ reason: string;
69
+ command: string;
70
+ since: string;
71
+ }[];
65
72
  };
66
73
  /** One indexed observation for `task wait`. `watchedRun` is the attempt the
67
74
  * caller first saw. Each poll follows the task's newest attempt from there, so
@@ -2,6 +2,7 @@
2
2
  * this module never opens a repository, retained evidence, or a live log. */
3
3
  import { COMPLETION_ACTION } from "./result-completion.js";
4
4
  import { windowLabel } from "./provider-limits.js";
5
+ import { openAuthPauses, signInCommand, signInReason } from "./provider-auth.js";
5
6
  import { BUILT_IN, checkSuitesOf } from "./store.js";
6
7
  const maybeNumber = (value) => value === null || value === undefined || !Number.isInteger(Number(value)) ? null : Number(value);
7
8
  const CHECK_COLUMNS = "rc.status AS check_status, rc.exit_code AS check_exit_code, rc.suites_json AS check_suites_json";
@@ -192,8 +193,16 @@ export function installationStatus(store, now) {
192
193
  WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN);
193
194
  const reasons = new Map();
194
195
  const queuedTasks = [];
196
+ const pauses = openAuthPauses(store);
195
197
  for (const row of queuedRows) {
196
- const reason = queueReason(row);
198
+ let reason = queueReason(row);
199
+ if (reason === "ready for a worker" && pauses.length > 0) {
200
+ const scope = store.getScope(String(row["id"]));
201
+ const provider = scope?.approvedProfile?.provider ?? scope?.profile?.provider;
202
+ const paused = pauses.find(one => one.provider === provider);
203
+ if (paused !== undefined)
204
+ reason = signInReason(paused);
205
+ }
197
206
  reasons.set(reason, (reasons.get(reason) ?? 0) + 1);
198
207
  if (queuedTasks.length < 8)
199
208
  queuedTasks.push({ task: String(row["id"]), reason });
@@ -243,10 +252,14 @@ export function installationStatus(store, now) {
243
252
  reached: Number(row["reached"]) === 1,
244
253
  observedAt: String(row["observed_at"]),
245
254
  })),
255
+ signIn: pauses.map(one => ({ provider: one.provider, reason: signInReason(one), command: signInCommand(one), since: one.openedAt })),
246
256
  };
247
257
  }
248
258
  export function renderInstallationStatus(status) {
249
259
  const lines = [];
260
+ // A paused provider comes first: it is the one thing a person must do.
261
+ for (const one of status.signIn)
262
+ lines.push(`${one.reason} — run \`${one.command}\`. Its tasks wait until then.`);
250
263
  lines.push(status.running.count === 0 ? "Running: none" : `Running: ${status.running.count} — ${status.running.tasks.map(one => `${one.task} (#${one.run}, ${one.phase})`).join(", ")}${status.running.count > status.running.tasks.length ? ", …" : ""}`);
251
264
  lines.push(status.queued.count === 0 ? "Queued: none" : `Queued: ${status.queued.count} — ${status.queued.tasks.map(one => `${one.task} (${one.reason})`).join(", ")}${status.queued.count > status.queued.tasks.length ? ", …" : ""}`);
252
265
  lines.push(status.waitingForReview.count === 0 ? "Ready for review: none" : `Ready for review: ${status.waitingForReview.count} — ${status.waitingForReview.results.map(one => `${one.task} (#${one.run})`).join(", ")}${status.waitingForReview.count > status.waitingForReview.results.length ? ", …" : ""}`);
@@ -276,7 +289,7 @@ export function renderInstallationStatus(status) {
276
289
  lines.push(`Plan windows: ${provider} — ${detail}`);
277
290
  }
278
291
  }
279
- return lines.slice(0, 12);
292
+ return lines.slice(0, 12 + status.signIn.length);
280
293
  }
281
294
  export function renderTaskWait(snapshot, outcome = snapshot.outcome) {
282
295
  const check = snapshot.check.status === "unknown" ? "checks unknown" : snapshot.check.status === "not-run" ? "checks not run" : `checks ${snapshot.check.status}${snapshot.check.exitCode === null ? "" : ` (exit ${snapshot.check.exitCode})`}`;
package/dist/mate.js CHANGED
@@ -410,10 +410,13 @@ export async function runMateTurn(input) {
410
410
  const outcome = executeMateTool({ store, who, now: clock(), draft, selectEvidence, step: steps, readDecisions, readResults, ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), ...(input.mediaDelivery === undefined ? {} : { mediaDelivery: input.mediaDelivery }) }, call.name, call.args, view);
411
411
  if (READ_TOOLS.has(call.name))
412
412
  reads++;
413
- // Opt-in, local diagnostics for end-to-end runs: what the lead asked of each tool and what came back, keys blanked.
413
+ // Opt-in, local diagnostics for end-to-end runs: what the lead asked of each tool and what came back (its start), keys
414
+ // blanked. Each line stays whole JSON so a run can assert on what a tool returned rather than on the model's words.
414
415
  if (envValue(process.env, "MATE_TRACE") === "1") {
415
416
  const blanked = (_, value) => typeof value === "string" ? redactSecretAssignments(redactSecretLines(value, scanForSecrets(value))) : value;
416
- process.stderr.write(`mate-trace ${JSON.stringify({ turn: turnId, step: steps, tool: call.name, args: call.args, ok: outcome.ok, ...(outcome.ok ? {} : { message: outcome.message }) }, blanked).slice(0, 4000)}\n`);
417
+ const args = JSON.stringify(call.args, blanked).slice(0, 1500);
418
+ const result = JSON.stringify(outcome.ok ? outcome.body : null, blanked)?.slice(0, 2000) ?? null;
419
+ process.stderr.write(`mate-trace ${JSON.stringify({ turn: turnId, step: steps, tool: call.name, args: args.length < 1500 ? call.args : args, ok: outcome.ok, ...(outcome.ok ? { result } : { message: outcome.message }) }, blanked)}\n`);
417
420
  }
418
421
  history.push({ role: "tool", callId: call.id, name: call.name, result: capped(outcome.ok ? outcome.body : { ok: false, message: outcome.message }) });
419
422
  }
package/dist/operate.d.ts CHANGED
@@ -6,6 +6,7 @@ import { type TelegramTransport } from "./telegram.js";
6
6
  import { type MateCliSeams } from "./mate-cli.js";
7
7
  import { type PublishExec } from "./publish.js";
8
8
  import { type Runner as CommandRunner } from "./builder.js";
9
+ import { type ReleaseIo } from "./releases.js";
9
10
  export type Write = (line: string) => void;
10
11
  /**
11
12
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
@@ -33,6 +34,10 @@ export type OperateOptions = {
33
34
  additionalProjectRepos?: () => readonly string[];
34
35
  /** Overridden by tests and by an agent that wants its own queue. */
35
36
  databaseFile?: string;
37
+ /** Injected by tests: how `status` and the console ask for the latest release. */
38
+ releaseIo?: ReleaseIo;
39
+ /** Injected by tests: the bin whose real path says how Toolroll was installed. */
40
+ installBin?: string;
36
41
  openDatabase?: (file: string) => Store;
37
42
  now?: Date;
38
43
  /**