toolroll 0.6.0 → 0.8.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 (45) hide show
  1. package/CHANGELOG.md +413 -0
  2. package/README.md +15 -0
  3. package/dist/agent-onboard.d.ts +106 -0
  4. package/dist/agent-onboard.js +282 -0
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +23 -23
  7. package/dist/browser-shell.js +5 -3
  8. package/dist/browser-workspace.d.ts +45 -0
  9. package/dist/cli.d.ts +3 -1
  10. package/dist/cli.js +8 -1
  11. package/dist/control-setup.d.ts +4 -4
  12. package/dist/daemon.d.ts +4 -0
  13. package/dist/daemon.js +28 -4
  14. package/dist/desktop-host.js +2 -2
  15. package/dist/desktop-update.d.ts +7 -1
  16. package/dist/desktop-update.js +39 -22
  17. package/dist/evidence.d.ts +24 -0
  18. package/dist/evidence.js +103 -5
  19. package/dist/first-run.d.ts +54 -0
  20. package/dist/first-run.js +101 -0
  21. package/dist/install-method.d.ts +17 -0
  22. package/dist/install-method.js +65 -0
  23. package/dist/operate.d.ts +14 -0
  24. package/dist/operate.js +114 -18
  25. package/dist/provider-auth.d.ts +33 -8
  26. package/dist/provider-auth.js +54 -13
  27. package/dist/releases.d.ts +85 -0
  28. package/dist/releases.js +238 -0
  29. package/dist/serve.d.ts +18 -0
  30. package/dist/serve.js +237 -81
  31. package/dist/skills.d.ts +2 -0
  32. package/dist/skills.js +3 -1
  33. package/dist/store.d.ts +3 -3
  34. package/dist/store.js +9 -4
  35. package/dist/surface.js +2 -0
  36. package/dist/toolroll-update-cli.d.ts +15 -0
  37. package/dist/toolroll-update-cli.js +155 -0
  38. package/dist/toolroll-update-ui.d.ts +38 -0
  39. package/dist/toolroll-update-ui.js +89 -0
  40. package/dist/toolroll-update.d.ts +235 -0
  41. package/dist/toolroll-update.js +929 -0
  42. package/dist/update-notice.d.ts +5 -0
  43. package/dist/update-notice.js +4 -0
  44. package/dist/work-index.js +5 -3
  45. package/package.json +2 -1
@@ -0,0 +1,54 @@
1
+ import type { Runner } from "./backend.js";
2
+ export declare const SIGN_IN_COMMAND = "claude auth login";
3
+ export declare const SANDBOX_COMMAND = "npx toolroll demo";
4
+ export type FirstRunAction = {
5
+ kind: "link";
6
+ label: string;
7
+ href: string;
8
+ } | {
9
+ kind: "command";
10
+ command: string;
11
+ };
12
+ export type FirstRunStep = {
13
+ key: "agent" | "project" | "task";
14
+ title: string;
15
+ done: boolean;
16
+ action: FirstRunAction | null;
17
+ };
18
+ export type FirstRunFacts = {
19
+ agentSignedIn: boolean;
20
+ projects: number;
21
+ hasTask: boolean;
22
+ firstResultAt: string | null;
23
+ };
24
+ /** The three steps, each done or with the one action that does it; null once the first result has arrived. */
25
+ export declare function firstRunSteps(facts: FirstRunFacts): FirstRunStep[] | null;
26
+ export type OpenIssue = {
27
+ number: number;
28
+ title: string;
29
+ };
30
+ export type TodoComment = {
31
+ file: string;
32
+ line: number;
33
+ text: string;
34
+ };
35
+ export type FirstTaskSuggestion = {
36
+ source: "issue" | "todo" | "generic";
37
+ label: string;
38
+ draft: string;
39
+ };
40
+ export declare const GENERIC_FIRST_TASKS: readonly FirstTaskSuggestion[];
41
+ /** Issue titles and TODO text are written by anyone: keep only what a person can see, so the draft they send is the
42
+ * draft the lead reads (no control, format, zero-width, bidi, tag or variation-selector characters). */
43
+ export declare const visibleText: (text: string) => string;
44
+ /** Three first tasks: open GitHub issues first, then TODO or FIXME comments, then safe generic ones. */
45
+ export declare function firstTaskSuggestions(found: {
46
+ issues: readonly OpenIssue[];
47
+ todos: readonly TodoComment[];
48
+ }): FirstTaskSuggestion[];
49
+ /** `git grep` lines ("file:line:text") to TODO or FIXME notes, one per file. */
50
+ export declare function parseTodoLines(stdout: string, limit?: number): TodoComment[];
51
+ /** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
52
+ export declare function findFirstTasks(repo: string, runner?: Runner): Promise<FirstTaskSuggestion[]>;
53
+ /** "First result in 7 min": how long after the installation began its first Ready result arrived. */
54
+ export declare function firstResultWords(since: string, at: string): string | null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The first run: three plain steps to a first result, and three first tasks
3
+ * to try. Everything here is derived from live facts on each render; the
4
+ * list retires permanently with the first Ready result (the
5
+ * 'first-success-at' installation fact). A suggestion only drafts words in
6
+ * the composer: nothing is filed until the person sends it and confirms.
7
+ */
8
+ import { run } from "./exec.js";
9
+ import { githubIssues } from "./issues.js";
10
+ export const SIGN_IN_COMMAND = "claude auth login";
11
+ export const SANDBOX_COMMAND = "npx toolroll demo";
12
+ /** The three steps, each done or with the one action that does it; null once the first result has arrived. */
13
+ export function firstRunSteps(facts) {
14
+ if (facts.firstResultAt !== null)
15
+ return null;
16
+ const project = facts.projects > 0;
17
+ return [
18
+ { key: "agent", title: "Agent signed in", done: facts.agentSignedIn, action: facts.agentSignedIn ? null : { kind: "command", command: SIGN_IN_COMMAND } },
19
+ { key: "project", title: "Project added", done: project, action: project ? null : { kind: "link", label: "Add a project", href: "/projects" } },
20
+ { key: "task", title: "Your first task", done: facts.hasTask, action: facts.hasTask ? null : { kind: "link", label: "New task", href: "/tasks/new" } },
21
+ ];
22
+ }
23
+ export const GENERIC_FIRST_TASKS = [
24
+ { source: "generic", label: "Add a test for an untested function", draft: "Find one function without a test and add a focused test for it." },
25
+ { source: "generic", label: "Fix a lint warning", draft: "Fix one lint warning without changing behaviour." },
26
+ { source: "generic", label: "Improve the README's setup section", draft: "Improve the setup section of the README so a newcomer can install and run the project." },
27
+ ];
28
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text);
29
+ /** Issue titles and TODO text are written by anyone: keep only what a person can see, so the draft they send is the
30
+ * draft the lead reads (no control, format, zero-width, bidi, tag or variation-selector characters). */
31
+ export const visibleText = (text) => text.replace(/[\p{Cf}\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}]/gu, "").replace(/[\p{Cc}\s]+/gu, " ").trim();
32
+ /** Three first tasks: open GitHub issues first, then TODO or FIXME comments, then safe generic ones. */
33
+ export function firstTaskSuggestions(found) {
34
+ const fromIssues = found.issues.map(({ number, title: raw }) => {
35
+ const title = visibleText(raw);
36
+ return { source: "issue", label: clip(`#${number} ${title}`, 80), draft: `Fix GitHub issue #${number}: ${title}` };
37
+ });
38
+ const fromTodos = found.todos.map(({ file: rawFile, line, text: rawText }) => ({ file: visibleText(rawFile), line, text: visibleText(rawText) })).map((todo) => ({
39
+ source: "todo", label: clip(todo.text === "" ? `The note in ${todo.file}` : todo.text.charAt(0).toUpperCase() + todo.text.slice(1), 80),
40
+ draft: `Resolve the note in ${todo.file} line ${todo.line}${todo.text === "" ? "" : `: ${todo.text}`}`,
41
+ }));
42
+ return [...fromIssues, ...fromTodos, ...GENERIC_FIRST_TASKS].slice(0, 3);
43
+ }
44
+ const SKIPPED = /(^|\/)(node_modules|dist|build|vendor|third_party|\.git)\//;
45
+ /** `git grep` lines ("file:line:text") to TODO or FIXME notes, one per file. */
46
+ export function parseTodoLines(stdout, limit = 3) {
47
+ const found = [];
48
+ const seen = new Set();
49
+ for (const line of stdout.split("\n")) {
50
+ const match = /^(.+?):(\d+):(.*)$/.exec(line);
51
+ if (match === null || SKIPPED.test(match[1]) || seen.has(match[1]))
52
+ continue;
53
+ const note = /\b(?:TODO|FIXME)\b(?:\([^)]*\))?[:\s-]*(.*)$/.exec(match[3]);
54
+ if (note === null)
55
+ continue;
56
+ seen.add(match[1]);
57
+ found.push({ file: match[1], line: Number(match[2]), text: clip(note[1].replace(/\s*(\*\/|-->)\s*$/, "").trim(), 120) });
58
+ if (found.length === limit)
59
+ break;
60
+ }
61
+ return found;
62
+ }
63
+ /** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
64
+ export async function findFirstTasks(repo, runner = run) {
65
+ let issues = [];
66
+ try {
67
+ const listed = await githubIssues({ repo, runner, limit: 3, timeoutMs: 5_000 }).listReady();
68
+ if (listed.ok)
69
+ issues = listed.value.slice(0, 3).map(one => ({ number: Number(one.id), title: one.title })).filter(one => Number.isSafeInteger(one.number));
70
+ }
71
+ catch {
72
+ issues = [];
73
+ }
74
+ let todos = [];
75
+ if (issues.length < 3) {
76
+ try {
77
+ const result = await runner("git", ["grep", "-n", "-I", "-w", "--max-count=1", "-E", "TODO|FIXME"], { cwd: repo, timeoutMs: 5_000 });
78
+ if (result.code === 0 || result.stdout !== "")
79
+ todos = parseTodoLines(result.stdout);
80
+ }
81
+ catch {
82
+ todos = [];
83
+ }
84
+ }
85
+ return firstTaskSuggestions({ issues, todos });
86
+ }
87
+ /** "First result in 7 min": how long after the installation began its first Ready result arrived. */
88
+ export function firstResultWords(since, at) {
89
+ const minutes = Math.round((Date.parse(at) - Date.parse(since)) / 60_000);
90
+ if (!Number.isFinite(minutes) || minutes < 0)
91
+ return null;
92
+ if (minutes < 1)
93
+ return "First result in under a minute";
94
+ if (minutes < 60)
95
+ return `First result in ${minutes} min`;
96
+ if (minutes < 48 * 60) {
97
+ const hours = Math.floor(minutes / 60), rest = minutes % 60;
98
+ return `First result in ${hours} h${rest === 0 ? "" : ` ${rest} min`}`;
99
+ }
100
+ return `First result in ${Math.round(minutes / (24 * 60))} days`;
101
+ }
@@ -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" | "managed";
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,65 @@
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
+ // A runtime `toolroll update` installed (staged-upgrades/release-* or rollback-*): it updates itself.
17
+ managed: "toolroll update",
18
+ };
19
+ /** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
20
+ export function installMethod(bin = process.argv[1], probe = {}) {
21
+ const kind = installKind(bin, probe);
22
+ return { kind, updateCommand: UPDATE_COMMANDS[kind] };
23
+ }
24
+ function installKind(bin, probe) {
25
+ if (bin === undefined || bin === "")
26
+ return "npm";
27
+ let real = bin;
28
+ try {
29
+ real = (probe.realpath ?? realpathSync)(bin);
30
+ }
31
+ catch {
32
+ // A bin that cannot be resolved is judged by the path it was run by.
33
+ }
34
+ const path = real.replace(/\\/g, "/");
35
+ // A plane deployed from a checkout by scripts/deploy-browser.mjs runs from staged-upgrades/browser-*: it updates
36
+ // by deploying from source, never from npm.
37
+ if (path.includes("/staged-upgrades/browser-"))
38
+ return "source";
39
+ if (/\/staged-upgrades\/(release|rollback)-/.test(path))
40
+ return "managed";
41
+ if (/\/Cellar\/toolroll\//i.test(path))
42
+ return "homebrew";
43
+ if (path.includes("/_npx/"))
44
+ return "npx";
45
+ if (/\/\.bun\/install\/global\//.test(path))
46
+ return "bun";
47
+ if (/\/pnpm\/global\//i.test(path))
48
+ return "pnpm";
49
+ if (/\/(?:\.config\/yarn|yarn\/data)\/global\//i.test(path))
50
+ return "yarn";
51
+ if (path.includes("/node_modules/"))
52
+ return "npm";
53
+ // Not inside node_modules: a checkout, when the package's own folder is a git working copy.
54
+ const exists = probe.exists ?? existsSync;
55
+ let folder = dirname(real);
56
+ for (let depth = 0; depth < 4; depth++) {
57
+ if (exists(join(folder, "package.json")) && exists(join(folder, ".git")))
58
+ return "source";
59
+ const parent = dirname(folder);
60
+ if (parent === folder)
61
+ break;
62
+ folder = parent;
63
+ }
64
+ return "npm";
65
+ }
package/dist/operate.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type OnboardIo } from "./agent-onboard.js";
1
2
  import { type TriggerIo } from "./flow-triggers.js";
2
3
  import { type StepIo } from "./flow-steps.js";
3
4
  import { type Store } from "./store.js";
@@ -6,6 +7,7 @@ import { type TelegramTransport } from "./telegram.js";
6
7
  import { type MateCliSeams } from "./mate-cli.js";
7
8
  import { type PublishExec } from "./publish.js";
8
9
  import { type Runner as CommandRunner } from "./builder.js";
10
+ import { type ReleaseIo } from "./releases.js";
9
11
  export type Write = (line: string) => void;
10
12
  /**
11
13
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
@@ -33,6 +35,10 @@ export type OperateOptions = {
33
35
  additionalProjectRepos?: () => readonly string[];
34
36
  /** Overridden by tests and by an agent that wants its own queue. */
35
37
  databaseFile?: string;
38
+ /** Injected by tests: how `status` and the console ask for the latest release. */
39
+ releaseIo?: ReleaseIo;
40
+ /** Injected by tests: the bin whose real path says how Toolroll was installed. */
41
+ installBin?: string;
36
42
  openDatabase?: (file: string) => Store;
37
43
  now?: Date;
38
44
  /**
@@ -61,7 +67,15 @@ export type OperateOptions = {
61
67
  heldCoordinator?: import("./held.js").HeldSessionCoordinator;
62
68
  /** Test seam for the short indexed wait loop. */
63
69
  waitSleep?: (milliseconds: number) => Promise<void>;
70
+ /** Injected by tests: `onboard`'s home folder, terminal and probes. */
71
+ onboardSeams?: OnboardSeams;
72
+ /** Injected by tests: whether `up` has a person at a terminal, and how it opens a browser. */
73
+ upSeams?: {
74
+ terminal?: boolean;
75
+ openBrowser?: (url: string) => void;
76
+ };
64
77
  };
78
+ export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection">>;
65
79
  export declare const OPERATE_HELP: string;
66
80
  /** Parsed flags, with the positionals left over. */
67
81
  type Args = {
package/dist/operate.js CHANGED
@@ -12,6 +12,7 @@ import { buildExport, exportSummary, exportZip, writeExportFolder } from "./expo
12
12
  import { startBudgetAlerts } from "./budget-alerts.js";
13
13
  import { liftAuthPause, openAuthPauses, signInGate, signInWords, startSignInProbes } from "./provider-auth.js";
14
14
  import { createConnectionChecker } from "./provider-connection.js";
15
+ import { buildHandoff, handoffLines, loginAccount, runOnboard } from "./agent-onboard.js";
15
16
  import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
16
17
  import { pushLimitSink } from "./provider-limits.js";
17
18
  import { limitsView } from "./limits-ui.js";
@@ -181,6 +182,9 @@ import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
181
182
  import { beads } from "./beads.js";
182
183
  import { githubIssues } from "./issues.js";
183
184
  import { installationStatus, renderInstallationStatus, renderTaskWait, taskWaitSnapshot } from "./lead-status.js";
185
+ import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
186
+ import { installMethod } from "./install-method.js";
187
+ import { PACKAGE_VERSION } from "./version.js";
184
188
  /**
185
189
  * 0 done · 1 broke · 2 bad usage · 3 ran fine, the answer is no.
186
190
  *
@@ -562,13 +566,13 @@ export const OPERATE_VALUE_FLAGS = new Set([
562
566
  "command", "timeout-seconds", "setup-digest", "stop-grace", "title", "name", "every", "lines",
563
567
  "label", "reviewers", "limit", "role", "key-file", "weekly-usd", "daily-turns", "per-hour", "token-file", "race", "compare", "race-per-usd", "race-total-usd", "race-count", "race-agents", "budget-usd", "build-usd", "sync-max-age", "merge-method",
564
568
  "phase", "risk", "tier", "clear-phase",
565
- "run", "containment",
569
+ "run", "containment", "agent",
566
570
  "token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
567
571
  ]);
568
572
  export const OPERATE_BOOLEAN_FLAGS = new Set([
569
573
  "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
570
574
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
571
- "no-open", "no-verify", "no-follow", "end", "report", "off", "tmux",
575
+ "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
572
576
  "self-heal", "plan-auto", "repair-auto", "review-retry-auto", "no-local",
573
577
  "html", "csv", "alerts-only",
574
578
  // v105: the full export.
@@ -720,6 +724,10 @@ export async function runOperate(command, argv, write, options = {}) {
720
724
  ...(options.mateSeams === undefined ? {} : { mateSeams: options.mateSeams }),
721
725
  ...(options.heldCoordinator === undefined ? {} : { heldCoordinator: options.heldCoordinator }),
722
726
  ...(options.waitSleep === undefined ? {} : { waitSleep: options.waitSleep }),
727
+ ...(options.releaseIo === undefined ? {} : { releaseIo: options.releaseIo }),
728
+ ...(options.installBin === undefined ? {} : { installBin: options.installBin }),
729
+ ...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
730
+ ...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
723
731
  });
724
732
  }
725
733
  catch (error) {
@@ -934,6 +942,8 @@ async function dispatch(command, positional, flags, context) {
934
942
  return watchCommand(flags, context);
935
943
  case "up":
936
944
  return upCommand(flags, context);
945
+ case "onboard":
946
+ return onboardCommand(flags, context);
937
947
  case "daemon":
938
948
  return daemonCommand(positional, flags, context);
939
949
  case "bridge":
@@ -2127,10 +2137,10 @@ async function tickCommand(flags, context) {
2127
2137
  continue;
2128
2138
  }
2129
2139
  // The sign-in pause holds a resumed lane too: it waits, parked, instead of failing.
2130
- const laneSignedOut = signInGate(store, [racer.provider], clock());
2131
- if (laneSignedOut !== null) {
2140
+ const laneGate = signInGate(store, [racer.provider], clock());
2141
+ if (laneGate.waiting !== null) {
2132
2142
  backToParked();
2133
- resumed.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(laneSignedOut) });
2143
+ resumed.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(laneGate.waiting) });
2134
2144
  continue;
2135
2145
  }
2136
2146
  // Legacy comparison lanes carried an absolute clock. New profiles use
@@ -2139,6 +2149,7 @@ async function tickCommand(flags, context) {
2139
2149
  // wall-clock contract.
2140
2150
  const remaining = waiting.kind === "comparison" ? null : racer.budgetMicrousd - racer.accountedMicrousd;
2141
2151
  if (remaining !== null && remaining <= 0) {
2152
+ laneGate.giveBack();
2142
2153
  const current = store.getContestant(racer.id);
2143
2154
  if (current !== null)
2144
2155
  store.casContestantState(racer.id, ["ready"], "stopped", current.generation);
@@ -2150,6 +2161,7 @@ async function tickCommand(flags, context) {
2150
2161
  const laneProfile = racer.profile ?? contestantProfileOf(racer.provider, racer.model, racer.repairModel);
2151
2162
  const clockCapMs = 3 * laneProfile.timeoutSeconds * 1000;
2152
2163
  if (laneProfile.timeoutKind !== "idle" && store.contestantCumulativeMs(racer.id) >= clockCapMs) {
2164
+ laneGate.giveBack();
2153
2165
  const current = store.getContestant(racer.id);
2154
2166
  if (current !== null)
2155
2167
  store.casContestantState(racer.id, ["ready"], "stopped", current.generation);
@@ -2160,12 +2172,14 @@ async function tickCommand(flags, context) {
2160
2172
  }
2161
2173
  const reclaimed = acquire(store, waiting.taskRef, runner, { now: clock(), token, ttlMs: leaseTtlMs });
2162
2174
  if (!reclaimed.ok) {
2175
+ laneGate.giveBack();
2163
2176
  backToParked();
2164
2177
  continue;
2165
2178
  }
2166
2179
  const freshContest = store.getContest(waiting.id);
2167
2180
  if (freshContest === null || !store.casContestState(waiting.id, ["decision-wait", "racing"], "racing", freshContest.generation)) {
2168
2181
  release(store, reclaimed.claim.leaseId, clock());
2182
+ laneGate.giveBack();
2169
2183
  backToParked();
2170
2184
  continue;
2171
2185
  }
@@ -2384,7 +2398,10 @@ async function tickCommand(flags, context) {
2384
2398
  // gateway re-checks freshly at spawn; this cache only keeps a skipped
2385
2399
  // queue from probing once per task.
2386
2400
  const attestedThisPass = new Map();
2401
+ let untakenTrial = null;
2387
2402
  for (const ref of ready) {
2403
+ untakenTrial?.giveBack();
2404
+ untakenTrial = null;
2388
2405
  // The build budget governs UNATTENDED admissions (round-1 finding 4):
2389
2406
  // once it is spent, the pass keeps SCANNING for attended
2390
2407
  // authorizations — operator-invoked sessions launch regardless —
@@ -2601,11 +2618,14 @@ async function tickCommand(flags, context) {
2601
2618
  // One task every AUTH_TRIAL_MS goes ahead as the trial: a sign-in check
2602
2619
  // can say "logged in" for a session that cannot refresh, so a real run
2603
2620
  // decides — it lifts the pause, or fails into the same incident.
2604
- const signedOut = signInGate(store, modeProviders, clock(), ref.id);
2605
- if (signedOut !== null) {
2606
- dispatched.push({ id, outcome: "skipped", reason: "signed-out", detail: signInWords(signedOut) });
2621
+ const gate = signInGate(store, modeProviders, clock(), ref.id);
2622
+ if (gate.waiting !== null) {
2623
+ dispatched.push({ id, outcome: "skipped", reason: "signed-out", detail: signInWords(gate.waiting) });
2607
2624
  continue;
2608
2625
  }
2626
+ // A trial whose task is skipped before its claim succeeds is given back
2627
+ // at the next task (or after the pass), so the next pass may take it.
2628
+ untakenTrial = gate;
2609
2629
  // The leg is the authority: what resolved must BE the leg, exactly —
2610
2630
  // the sealed build leg, or the parked fallback entry's own pair.
2611
2631
  const governingLeg = wantsPlan ? planLeg : parkedEntry !== null ? { provider: parkedEntry.provider, model: parkedEntry.model, chosen: "fallback" } : sealedBuildLeg;
@@ -2772,6 +2792,8 @@ async function tickCommand(flags, context) {
2772
2792
  ? {}
2773
2793
  : { maxOpenDecisions: Number(text(flags, "max-open-decisions")) }),
2774
2794
  });
2795
+ if (claimed.ok)
2796
+ untakenTrial = null;
2775
2797
  if (!claimed.ok) {
2776
2798
  // Losing a race, finding the task no longer ready, and a machine that
2777
2799
  // lacks what the task needs are all the system working. None fails the
@@ -3732,6 +3754,7 @@ async function tickCommand(flags, context) {
3732
3754
  break;
3733
3755
  }
3734
3756
  }
3757
+ untakenTrial?.giveBack();
3735
3758
  // THE CHAIN ADMISSION PASS (E3d): cycles a recognized exhaustion advanced
3736
3759
  // to pending-admission dispatch their NEXT approved entry here — the ONLY
3737
3760
  // road that runs a fallback entry. Every authority is re-derived inside
@@ -3779,9 +3802,9 @@ async function tickCommand(flags, context) {
3779
3802
  continue;
3780
3803
  }
3781
3804
  // The sign-in pause holds a fallback entry too: the admission stays pending until its provider works.
3782
- const entrySignedOut = signInGate(store, [peek.profile.provider], clock(), pending.taskRef);
3783
- if (entrySignedOut !== null) {
3784
- dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(entrySignedOut) });
3805
+ const entryGate = signInGate(store, [peek.profile.provider], clock(), pending.taskRef);
3806
+ if (entryGate.waiting !== null) {
3807
+ dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(entryGate.waiting) });
3785
3808
  continue;
3786
3809
  }
3787
3810
  // The FALLBACK claim (finding 4): every acquireIfReady gate — task
@@ -3806,6 +3829,7 @@ async function tickCommand(flags, context) {
3806
3829
  : { maxOpenDecisions: Number(text(flags, "max-open-decisions")) }),
3807
3830
  });
3808
3831
  if (!claimed.ok) {
3832
+ entryGate.giveBack();
3809
3833
  dispatched.push({ id: pending.taskId, outcome: "skipped", reason: claimed.reason });
3810
3834
  continue;
3811
3835
  }
@@ -3974,13 +3998,14 @@ async function tickCommand(flags, context) {
3974
3998
  continue;
3975
3999
  }
3976
4000
  // The sign-in pause holds a continuation too: its authorization stays open and it starts once the sign-in works.
3977
- const continuationSignedOut = signInGate(store, [pinned.provider], clock(), parent.taskRef);
3978
- if (continuationSignedOut !== null) {
3979
- dispatched.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(continuationSignedOut) });
4001
+ const continuationGate = signInGate(store, [pinned.provider], clock(), parent.taskRef);
4002
+ if (continuationGate.waiting !== null) {
4003
+ dispatched.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(continuationGate.waiting) });
3980
4004
  continue;
3981
4005
  }
3982
4006
  const claimed = acquireContinuation(store, continuation, runner, { now: clock(), token, ttlMs: leaseTtlMs });
3983
4007
  if (!claimed.ok) {
4008
+ continuationGate.giveBack();
3984
4009
  dispatched.push({ id: taskId, outcome: "skipped", reason: claimed.reason, ...("message" in claimed ? { detail: claimed.message } : {}) });
3985
4010
  continue;
3986
4011
  }
@@ -4683,6 +4708,8 @@ async function startConsole(options) {
4683
4708
  // Sprint 8: scheduled backups, a pass a minute; its lease also tells a restore that the console is running.
4684
4709
  server.on("close", startBackups(context.store, context.databaseFile));
4685
4710
  server.on("close", startCodexLimits(context.store));
4711
+ // At most one anonymous look a day for a newer Toolroll (Settings → Updates can switch it off).
4712
+ server.on("close", startUpdateChecks(context.store, dirname(context.databaseFile), PACKAGE_VERSION, installMethod(context.installBin).updateCommand, context.releaseIo ?? {}));
4686
4713
  const bound = server.address();
4687
4714
  const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
4688
4715
  // A bind-everywhere address is not a place a browser can go: the URL
@@ -7075,6 +7102,7 @@ async function runWatchLoop(args) {
7075
7102
  // attributes to this episode by runner and window, and `brief
7076
7103
  // --latest-watch` bounds itself to exactly it.
7077
7104
  store.startWatchEpisode({ repo, runner, incarnation }, new Date());
7105
+ recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
7078
7106
  args.onReady?.();
7079
7107
  // A false renewal is FATAL (arc 2 finding 15): the lease or the
7080
7108
  // credential is gone, and admitting one more pass would be work done for
@@ -7637,6 +7665,54 @@ function openBrowser(url) {
7637
7665
  // A browser that will not open is a URL the greeting already printed.
7638
7666
  }
7639
7667
  }
7668
+ /**
7669
+ * `onboard`: the agent that installed Toolroll becomes its lead. The work
7670
+ * lives in agent-onboard.ts; this wires it to the installation: the same
7671
+ * registry and project row Projects → add writes, and the same sign-in
7672
+ * checks Settings → AI providers makes.
7673
+ */
7674
+ async function onboardCommand(flags, context) {
7675
+ const { store, write, json, clock } = context;
7676
+ const seams = context.onboardSeams ?? {};
7677
+ const portGiven = text(flags, "port");
7678
+ const port = Number(portGiven ?? 4180);
7679
+ if (portGiven !== undefined && (!Number.isInteger(port) || port < 1 || port >= 65536)) {
7680
+ return fail(write, json, "onboard", "usage", "--port is a whole number under 65536", EXIT.usage);
7681
+ }
7682
+ const home = seams.home ?? homedir();
7683
+ const env = seams.env ?? process.env;
7684
+ const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
7685
+ const registryPath = registryPathOf(context);
7686
+ const checker = createConnectionChecker({ home, env });
7687
+ return runOnboard({
7688
+ write,
7689
+ json,
7690
+ yes: flag(flags, "yes"),
7691
+ remove: flag(flags, "remove"),
7692
+ agentFlag: text(flags, "agent"),
7693
+ url: `http://127.0.0.1:${port}`,
7694
+ loginFile: join(dirname(context.databaseFile), UP_LOGIN_FILE),
7695
+ home,
7696
+ env,
7697
+ version: PACKAGE_VERSION,
7698
+ cwd: seams.cwd ?? process.cwd(),
7699
+ interactive: seams.interactive ?? interactive(),
7700
+ confirm: seams.confirm ?? (async (question) => /^y(es)?$/i.test(await ask(`${question} `))),
7701
+ findRepo: seams.findRepo ?? (async (cwd) => {
7702
+ const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd });
7703
+ return top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
7704
+ }),
7705
+ enroll: async (repo) => {
7706
+ const before = await loadProjectRegistry(registryPath);
7707
+ const enrolled = await updateRepos(registryPath, repos => addRepos(repos, [repo]));
7708
+ if (!enrolled.ok)
7709
+ return { ok: false, message: enrolled.message };
7710
+ store.upsertProject(repo, projectName(repo), clock());
7711
+ return { ok: true, added: "error" in before || !before.repos.includes(repo) };
7712
+ },
7713
+ checkConnection: seams.checkConnection ?? (agent => checker(agent)),
7714
+ });
7715
+ }
7640
7716
  async function upCommand(flags, context) {
7641
7717
  const { store, write, json, clock } = context;
7642
7718
  const demoFence = refuseDemo(context, "up");
@@ -8064,6 +8140,7 @@ async function upCommand(flags, context) {
8064
8140
  });
8065
8141
  };
8066
8142
  observeReadiness();
8143
+ recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
8067
8144
  const runnerHeartbeat = setInterval(() => {
8068
8145
  const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
8069
8146
  if (!beat.ok && !stopping) {
@@ -8075,6 +8152,8 @@ async function upCommand(flags, context) {
8075
8152
  // 9. Readiness, then the ONE startup envelope / greeting (finding 11/20).
8076
8153
  const approverPlan2 = approver;
8077
8154
  const url = console_.url;
8155
+ const terminal = context.upSeams?.terminal ?? process.stdout.isTTY === true;
8156
+ const upHandoff = buildHandoff({ url, loginFile: approverPlan2.passwordFile, account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver });
8078
8157
  if (fatal === null) {
8079
8158
  if (json) {
8080
8159
  write(envelopeJson({
@@ -8087,8 +8166,17 @@ async function upCommand(flags, context) {
8087
8166
  approvers: approverPlan2.approvers,
8088
8167
  approverVerified: approverPlan2.verified,
8089
8168
  ...(approverPlan2.passwordFile === null ? {} : { passwordFile: approverPlan2.passwordFile }),
8169
+ handoff: upHandoff,
8090
8170
  }));
8091
8171
  }
8172
+ else if (!terminal) {
8173
+ // No person at a terminal: an agent started this. It gets the
8174
+ // handoff to relay, never the password, and no browser opens.
8175
+ write("");
8176
+ for (const line of handoffLines(upHandoff))
8177
+ write(line);
8178
+ write("Run `toolroll onboard` inside a repository to add it and install the Toolroll skill for your agent.");
8179
+ }
8092
8180
  else {
8093
8181
  write("");
8094
8182
  write(`The console is on ${url}`);
@@ -8113,8 +8201,8 @@ async function upCommand(flags, context) {
8113
8201
  write(" The inbox checklist shows what remains before approved work builds unattended.");
8114
8202
  write(" Ctrl-C stops Toolroll on this machine.");
8115
8203
  }
8116
- if (!json && !flags.has("no-open") && process.stdout.isTTY === true)
8117
- openBrowser(url);
8204
+ if (!json && !flags.has("no-open") && terminal)
8205
+ (context.upSeams?.openBrowser ?? openBrowser)(url);
8118
8206
  }
8119
8207
  // 10. Supervise to the end.
8120
8208
  await stopped;
@@ -8949,7 +9037,7 @@ function readPaths(flags, backend) {
8949
9037
  return backend === BUILT_IN ? [BUILT_IN] : [];
8950
9038
  }
8951
9039
  // ---- authoring ------------------------------------------------------------
8952
- function statusCommand(positional, flags, context) {
9040
+ async function statusCommand(positional, flags, context) {
8953
9041
  const command = "status";
8954
9042
  const allowed = new Set(["db", "json"]);
8955
9043
  for (const name of flags.keys()) {
@@ -8959,7 +9047,15 @@ function statusCommand(positional, flags, context) {
8959
9047
  if (positional.length > 0)
8960
9048
  return fail(context.write, context.json, command, "usage", "Use `toolroll status [--json]`.", EXIT.usage);
8961
9049
  const status = installationStatus(context.store, context.clock());
8962
- return succeed(context.write, context.json, command, { ...status }, () => renderInstallationStatus(status));
9050
+ // One line, only when a newer Toolroll exists; offline or switched off says nothing.
9051
+ const current = PACKAGE_VERSION;
9052
+ const method = installMethod(context.installBin);
9053
+ const release = newerRelease(await latestRelease(dirname(context.databaseFile), { now: context.clock, ...context.releaseIo }), current);
9054
+ if (release !== null)
9055
+ notifySecurityRelease(context.store, release, current, method.updateCommand, context.clock());
9056
+ const line = updateLine(release, current, method.updateCommand);
9057
+ const update = release === null ? {} : { update: { current, latest: release.version, security: release.security, updateCommand: method.updateCommand, url: release.url } };
9058
+ return succeed(context.write, context.json, command, { ...status, ...update }, () => [...renderInstallationStatus(status), ...(line === null ? [] : [line])]);
8963
9059
  }
8964
9060
  async function waitTask(positional, flags, context) {
8965
9061
  const command = "task wait";