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.
- package/CHANGELOG.md +413 -0
- package/README.md +15 -0
- package/dist/agent-onboard.d.ts +106 -0
- package/dist/agent-onboard.js +282 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +23 -23
- package/dist/browser-shell.js +5 -3
- package/dist/browser-workspace.d.ts +45 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +8 -1
- package/dist/control-setup.d.ts +4 -4
- package/dist/daemon.d.ts +4 -0
- package/dist/daemon.js +28 -4
- package/dist/desktop-host.js +2 -2
- package/dist/desktop-update.d.ts +7 -1
- package/dist/desktop-update.js +39 -22
- package/dist/evidence.d.ts +24 -0
- package/dist/evidence.js +103 -5
- package/dist/first-run.d.ts +54 -0
- package/dist/first-run.js +101 -0
- package/dist/install-method.d.ts +17 -0
- package/dist/install-method.js +65 -0
- package/dist/operate.d.ts +14 -0
- package/dist/operate.js +114 -18
- package/dist/provider-auth.d.ts +33 -8
- package/dist/provider-auth.js +54 -13
- package/dist/releases.d.ts +85 -0
- package/dist/releases.js +238 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +237 -81
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +3 -1
- package/dist/store.d.ts +3 -3
- package/dist/store.js +9 -4
- package/dist/surface.js +2 -0
- package/dist/toolroll-update-cli.d.ts +15 -0
- package/dist/toolroll-update-cli.js +155 -0
- package/dist/toolroll-update-ui.d.ts +38 -0
- package/dist/toolroll-update-ui.js +89 -0
- package/dist/toolroll-update.d.ts +235 -0
- package/dist/toolroll-update.js +929 -0
- package/dist/update-notice.d.ts +5 -0
- package/dist/update-notice.js +4 -0
- package/dist/work-index.js +5 -3
- 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
|
|
2131
|
-
if (
|
|
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(
|
|
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
|
|
2605
|
-
if (
|
|
2606
|
-
dispatched.push({ id, outcome: "skipped", reason: "signed-out", detail: signInWords(
|
|
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
|
|
3783
|
-
if (
|
|
3784
|
-
dispatched.push({ id: pending.taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(
|
|
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
|
|
3978
|
-
if (
|
|
3979
|
-
dispatched.push({ id: taskId, outcome: "skipped", reason: "signed-out", detail: signInWords(
|
|
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") &&
|
|
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
|
-
|
|
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";
|