toolroll 0.7.0 → 0.8.1
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 +435 -0
- package/README.md +15 -0
- package/dist/agent-onboard.d.ts +129 -0
- package/dist/agent-onboard.js +356 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +24 -23
- package/dist/browser-shell.js +5 -3
- package/dist/browser-workspace.d.ts +11 -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 +10 -2
- package/dist/daemon.js +34 -9
- package/dist/desktop-host.js +2 -2
- package/dist/desktop-update.d.ts +8 -2
- 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 +57 -0
- package/dist/first-run.js +104 -0
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +8 -0
- package/dist/operate.d.ts +11 -0
- package/dist/operate.js +126 -7
- package/dist/prompt.d.ts +6 -0
- package/dist/prompt.js +12 -0
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +14 -1
- package/dist/serve.d.ts +16 -0
- package/dist/serve.js +177 -82
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +3 -1
- package/dist/store.d.ts +4 -4
- package/dist/store.js +12 -6
- package/dist/surface.js +2 -0
- package/dist/toolroll-update-cli.d.ts +15 -0
- package/dist/toolroll-update-cli.js +159 -0
- package/dist/toolroll-update-ui.d.ts +38 -0
- package/dist/toolroll-update-ui.js +90 -0
- package/dist/toolroll-update.d.ts +269 -0
- package/dist/toolroll-update.js +1253 -0
- package/package.json +2 -1
|
@@ -0,0 +1,57 @@
|
|
|
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 -z -n` records ("file\0line\0text\n") to TODO or FIXME notes, one per file. NUL-separated so a
|
|
50
|
+
* file name with a colon or a newline in it is still one file name. */
|
|
51
|
+
export declare function parseTodoLines(stdout: string, limit?: number): TodoComment[];
|
|
52
|
+
/** The TODO search: no colour and no column whatever the person's git config says, NUL after each file name. */
|
|
53
|
+
export declare const TODO_GREP_ARGS: readonly string[];
|
|
54
|
+
/** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
|
|
55
|
+
export declare function findFirstTasks(repo: string, runner?: Runner): Promise<FirstTaskSuggestion[]>;
|
|
56
|
+
/** "First result in 7 min": how long after the installation began its first Ready result arrived. */
|
|
57
|
+
export declare function firstResultWords(since: string, at: string): string | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
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 -z -n` records ("file\0line\0text\n") to TODO or FIXME notes, one per file. NUL-separated so a
|
|
46
|
+
* file name with a colon or a newline in it is still one file name. */
|
|
47
|
+
export function parseTodoLines(stdout, limit = 3) {
|
|
48
|
+
const found = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const match of stdout.matchAll(/([^\0]*)\0(\d+)\0([^\n]*)(?:\n|$)/g)) {
|
|
51
|
+
const file = match[1];
|
|
52
|
+
if (file === "" || SKIPPED.test(file) || seen.has(file))
|
|
53
|
+
continue;
|
|
54
|
+
const note = /\b(?:TODO|FIXME)\b(?:\([^)]*\))?[:\s-]*(.*)$/.exec(match[3]);
|
|
55
|
+
if (note === null)
|
|
56
|
+
continue;
|
|
57
|
+
seen.add(file);
|
|
58
|
+
found.push({ file, line: Number(match[2]), text: clip(note[1].replace(/\s*(\*\/|-->)\s*$/, "").trim(), 120) });
|
|
59
|
+
if (found.length === limit)
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
/** The TODO search: no colour and no column whatever the person's git config says, NUL after each file name. */
|
|
65
|
+
export const TODO_GREP_ARGS = ["-c", "grep.column=false", "grep", "--no-color", "-z", "-n", "-I", "-w", "--max-count=1", "-E", "TODO|FIXME"];
|
|
66
|
+
/** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
|
|
67
|
+
export async function findFirstTasks(repo, runner = run) {
|
|
68
|
+
let issues = [];
|
|
69
|
+
try {
|
|
70
|
+
const listed = await githubIssues({ repo, runner, limit: 3, timeoutMs: 5_000 }).listReady();
|
|
71
|
+
if (listed.ok)
|
|
72
|
+
issues = listed.value.slice(0, 3).map(one => ({ number: Number(one.id), title: one.title })).filter(one => Number.isSafeInteger(one.number));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
issues = [];
|
|
76
|
+
}
|
|
77
|
+
let todos = [];
|
|
78
|
+
if (issues.length < 3) {
|
|
79
|
+
try {
|
|
80
|
+
const result = await runner("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
|
|
81
|
+
if (result.code === 0 || result.stdout !== "")
|
|
82
|
+
todos = parseTodoLines(result.stdout);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
todos = [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return firstTaskSuggestions({ issues, todos });
|
|
89
|
+
}
|
|
90
|
+
/** "First result in 7 min": how long after the installation began its first Ready result arrived. */
|
|
91
|
+
export function firstResultWords(since, at) {
|
|
92
|
+
const minutes = Math.round((Date.parse(at) - Date.parse(since)) / 60_000);
|
|
93
|
+
if (!Number.isFinite(minutes) || minutes < 0)
|
|
94
|
+
return null;
|
|
95
|
+
if (minutes < 1)
|
|
96
|
+
return "First result in under a minute";
|
|
97
|
+
if (minutes < 60)
|
|
98
|
+
return `First result in ${minutes} min`;
|
|
99
|
+
if (minutes < 48 * 60) {
|
|
100
|
+
const hours = Math.floor(minutes / 60), rest = minutes % 60;
|
|
101
|
+
return `First result in ${hours} h${rest === 0 ? "" : ` ${rest} min`}`;
|
|
102
|
+
}
|
|
103
|
+
return `First result in ${Math.round(minutes / (24 * 60))} days`;
|
|
104
|
+
}
|
package/dist/install-method.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the one command that updates it that way. Only the path is read: nothing is
|
|
4
4
|
* run and nothing leaves the computer.
|
|
5
5
|
*/
|
|
6
|
-
export type InstallKind = "npm" | "pnpm" | "bun" | "yarn" | "homebrew" | "npx" | "source";
|
|
6
|
+
export type InstallKind = "npm" | "pnpm" | "bun" | "yarn" | "homebrew" | "npx" | "source" | "managed" | "desktop";
|
|
7
7
|
export type InstallMethod = {
|
|
8
8
|
kind: InstallKind;
|
|
9
9
|
updateCommand: string;
|
package/dist/install-method.js
CHANGED
|
@@ -13,6 +13,10 @@ export const UPDATE_COMMANDS = {
|
|
|
13
13
|
homebrew: "brew upgrade ap9000/toolroll/toolroll",
|
|
14
14
|
npx: "npx toolroll@latest",
|
|
15
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
|
+
// The Toolroll app carries its own runtime and updates as a whole app, never through a package manager.
|
|
19
|
+
desktop: "Update from the Toolroll app",
|
|
16
20
|
};
|
|
17
21
|
/** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
|
|
18
22
|
export function installMethod(bin = process.argv[1], probe = {}) {
|
|
@@ -34,6 +38,10 @@ function installKind(bin, probe) {
|
|
|
34
38
|
// by deploying from source, never from npm.
|
|
35
39
|
if (path.includes("/staged-upgrades/browser-"))
|
|
36
40
|
return "source";
|
|
41
|
+
if (/\.app\/Contents\/Resources\//.test(path))
|
|
42
|
+
return "desktop";
|
|
43
|
+
if (/\/staged-upgrades\/(release|rollback)-/.test(path))
|
|
44
|
+
return "managed";
|
|
37
45
|
if (/\/Cellar\/toolroll\//i.test(path))
|
|
38
46
|
return "homebrew";
|
|
39
47
|
if (path.includes("/_npx/"))
|
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";
|
|
@@ -66,7 +67,16 @@ export type OperateOptions = {
|
|
|
66
67
|
heldCoordinator?: import("./held.js").HeldSessionCoordinator;
|
|
67
68
|
/** Test seam for the short indexed wait loop. */
|
|
68
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
|
+
env?: Record<string, string | undefined>;
|
|
76
|
+
openBrowser?: (url: string) => void;
|
|
77
|
+
};
|
|
69
78
|
};
|
|
79
|
+
export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection">>;
|
|
70
80
|
export declare const OPERATE_HELP: string;
|
|
71
81
|
/** Parsed flags, with the positionals left over. */
|
|
72
82
|
type Args = {
|
|
@@ -106,6 +116,7 @@ export declare function runOperate(command: string, argv: readonly string[], wri
|
|
|
106
116
|
* finding 27): exclusive, 0600, fsynced — file and directory both — so the
|
|
107
117
|
* row only ever follows a durable secret. Throws on any failure. */
|
|
108
118
|
export declare function writeLoginFileDurably(path: string, name: string, password: string): void;
|
|
119
|
+
export declare function readUpConsoleUrl(dir: string): string | null;
|
|
109
120
|
/**
|
|
110
121
|
* The people allowed to agree to a scope.
|
|
111
122
|
*
|
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";
|
|
@@ -73,7 +74,7 @@ import { sweepLiveLogs } from "./live.js";
|
|
|
73
74
|
import { configPath, addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
|
|
74
75
|
import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
|
|
75
76
|
import { pushPass } from "./push.js";
|
|
76
|
-
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
|
|
77
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
|
|
77
78
|
import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
|
|
78
79
|
import { createServer as createNetServer } from "node:net";
|
|
79
80
|
import { spawn as spawnChild } from "node:child_process";
|
|
@@ -127,7 +128,7 @@ import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLeg
|
|
|
127
128
|
import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
|
|
128
129
|
import { scanRepo } from "./capscan.js";
|
|
129
130
|
import { computeGaps, describeCapability } from "./gaps.js";
|
|
130
|
-
import { ask, askHidden, confirm, interactive } from "./prompt.js";
|
|
131
|
+
import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
|
|
131
132
|
import { runMateCli, answerContextLines } from "./mate-cli.js";
|
|
132
133
|
import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./mate-doors.js";
|
|
133
134
|
import { verifyApproverByPassword } from "./principal.js";
|
|
@@ -565,13 +566,13 @@ export const OPERATE_VALUE_FLAGS = new Set([
|
|
|
565
566
|
"command", "timeout-seconds", "setup-digest", "stop-grace", "title", "name", "every", "lines",
|
|
566
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",
|
|
567
568
|
"phase", "risk", "tier", "clear-phase",
|
|
568
|
-
"run", "containment",
|
|
569
|
+
"run", "containment", "agent",
|
|
569
570
|
"token-env", "after", "repair-max-attempts", "consumer", "batch", "feedback", "source", "view", "cursor", "why", "supersedes", "decision", "sessions", "timeout",
|
|
570
571
|
]);
|
|
571
572
|
export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
572
573
|
"json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
|
|
573
574
|
"clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
|
|
574
|
-
"no-open", "no-verify", "no-follow", "end", "report", "off", "tmux",
|
|
575
|
+
"no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
|
|
575
576
|
"self-heal", "plan-auto", "repair-auto", "review-retry-auto", "no-local",
|
|
576
577
|
"html", "csv", "alerts-only",
|
|
577
578
|
// v105: the full export.
|
|
@@ -725,6 +726,8 @@ export async function runOperate(command, argv, write, options = {}) {
|
|
|
725
726
|
...(options.waitSleep === undefined ? {} : { waitSleep: options.waitSleep }),
|
|
726
727
|
...(options.releaseIo === undefined ? {} : { releaseIo: options.releaseIo }),
|
|
727
728
|
...(options.installBin === undefined ? {} : { installBin: options.installBin }),
|
|
729
|
+
...(options.onboardSeams === undefined ? {} : { onboardSeams: options.onboardSeams }),
|
|
730
|
+
...(options.upSeams === undefined ? {} : { upSeams: options.upSeams }),
|
|
728
731
|
});
|
|
729
732
|
}
|
|
730
733
|
catch (error) {
|
|
@@ -939,6 +942,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
939
942
|
return watchCommand(flags, context);
|
|
940
943
|
case "up":
|
|
941
944
|
return upCommand(flags, context);
|
|
945
|
+
case "onboard":
|
|
946
|
+
return onboardCommand(flags, context);
|
|
942
947
|
case "daemon":
|
|
943
948
|
return daemonCommand(positional, flags, context);
|
|
944
949
|
case "bridge":
|
|
@@ -7660,6 +7665,102 @@ function openBrowser(url) {
|
|
|
7660
7665
|
// A browser that will not open is a URL the greeting already printed.
|
|
7661
7666
|
}
|
|
7662
7667
|
}
|
|
7668
|
+
/** Where `up` last served its console, kept beside the database so `onboard` names the same address. */
|
|
7669
|
+
const UP_CONSOLE_FILE = "up-console.json";
|
|
7670
|
+
function recordUpConsoleUrl(dir, url, now) {
|
|
7671
|
+
try {
|
|
7672
|
+
const file = join(dir, UP_CONSOLE_FILE), temporary = `${file}.${process.pid}.tmp`;
|
|
7673
|
+
writeFileSync(temporary, `${JSON.stringify({ url, at: now.toISOString() })}\n`, { mode: 0o600 });
|
|
7674
|
+
renameSync(temporary, file);
|
|
7675
|
+
}
|
|
7676
|
+
catch {
|
|
7677
|
+
// A display fact: a failed write only leaves onboard on the default port.
|
|
7678
|
+
}
|
|
7679
|
+
}
|
|
7680
|
+
export function readUpConsoleUrl(dir) {
|
|
7681
|
+
try {
|
|
7682
|
+
const parsed = JSON.parse(readFileSync(join(dir, UP_CONSOLE_FILE), "utf8"));
|
|
7683
|
+
return typeof parsed.url === "string" && /^https?:\/\/[^\s]+$/.test(parsed.url) ? parsed.url.replace(/\/+$/, "") : null;
|
|
7684
|
+
}
|
|
7685
|
+
catch {
|
|
7686
|
+
return null;
|
|
7687
|
+
}
|
|
7688
|
+
}
|
|
7689
|
+
/** How onboard says to sign in: the saved login file only while it still works (as `up` advertises it), else the
|
|
7690
|
+
* account and its password; null only when there is no account yet. Never the password itself. */
|
|
7691
|
+
function onboardLogin(store, loginFile) {
|
|
7692
|
+
const names = store.listApprovers().map(one => one.name);
|
|
7693
|
+
const saved = readLoginFile(loginFile);
|
|
7694
|
+
if (names.length === 0)
|
|
7695
|
+
return saved === null ? null : { account: saved.name, file: loginFile }; // the next `up` adopts it
|
|
7696
|
+
if (saved !== null && authenticateApprover(store, saved.name, saved.password).ok)
|
|
7697
|
+
return { account: saved.name, file: loginFile };
|
|
7698
|
+
return { account: names.length === 1 ? names[0] : null, file: null };
|
|
7699
|
+
}
|
|
7700
|
+
/**
|
|
7701
|
+
* `onboard`: the agent that installed Toolroll becomes its lead. The work
|
|
7702
|
+
* lives in agent-onboard.ts; this wires it to the installation: the same
|
|
7703
|
+
* registry and project row Projects → add writes, and the same sign-in
|
|
7704
|
+
* checks Settings → AI providers makes.
|
|
7705
|
+
*/
|
|
7706
|
+
async function onboardCommand(flags, context) {
|
|
7707
|
+
const { store, write, json, clock } = context;
|
|
7708
|
+
const seams = context.onboardSeams ?? {};
|
|
7709
|
+
const portGiven = text(flags, "port");
|
|
7710
|
+
const port = Number(portGiven ?? 4180);
|
|
7711
|
+
if (portGiven !== undefined && (!Number.isInteger(port) || port < 1 || port >= 65536)) {
|
|
7712
|
+
return fail(write, json, "onboard", "usage", "--port is a whole number under 65536", EXIT.usage);
|
|
7713
|
+
}
|
|
7714
|
+
// The console where `up` last served, unless --port says otherwise.
|
|
7715
|
+
const served = portGiven === undefined ? readUpConsoleUrl(dirname(context.databaseFile)) : null;
|
|
7716
|
+
const home = seams.home ?? homedir();
|
|
7717
|
+
const env = seams.env ?? process.env;
|
|
7718
|
+
const loginFile = join(dirname(context.databaseFile), UP_LOGIN_FILE);
|
|
7719
|
+
const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
|
|
7720
|
+
const registryPath = registryPathOf(context);
|
|
7721
|
+
const checker = createConnectionChecker({ home, env });
|
|
7722
|
+
return runOnboard({
|
|
7723
|
+
write,
|
|
7724
|
+
json,
|
|
7725
|
+
yes: flag(flags, "yes"),
|
|
7726
|
+
remove: flag(flags, "remove"),
|
|
7727
|
+
agentFlag: text(flags, "agent"),
|
|
7728
|
+
url: served ?? `http://127.0.0.1:${port}`,
|
|
7729
|
+
login: onboardLogin(store, loginFile),
|
|
7730
|
+
home,
|
|
7731
|
+
worktrees: join(dirname(context.databaseFile), "worktrees"),
|
|
7732
|
+
env,
|
|
7733
|
+
version: PACKAGE_VERSION,
|
|
7734
|
+
cwd: seams.cwd ?? process.cwd(),
|
|
7735
|
+
// An agent's shell may be a terminal, but nobody answers y/N there.
|
|
7736
|
+
interactive: seams.interactive ?? (interactive() && !underAgent(env)),
|
|
7737
|
+
confirm: seams.confirm ?? (async (question) => /^y(es)?$/i.test(await ask(`${question} `))),
|
|
7738
|
+
findRepo: seams.findRepo ?? (async (cwd) => {
|
|
7739
|
+
const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
7740
|
+
const topPath = top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
|
|
7741
|
+
if (topPath === null)
|
|
7742
|
+
return null;
|
|
7743
|
+
// A linked worktree's project is its main checkout: the folder whose .git is the common one.
|
|
7744
|
+
const common = await gitRun("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd });
|
|
7745
|
+
const commonDir = common.code === 0 ? common.stdout.trim() : "";
|
|
7746
|
+
const main = basename(commonDir) === ".git" ? canonicalProject(dirname(commonDir)) : null;
|
|
7747
|
+
return { top: topPath, main: main ?? topPath };
|
|
7748
|
+
}),
|
|
7749
|
+
enrolled: async (repo) => {
|
|
7750
|
+
const registry = await loadProjectRegistry(registryPath);
|
|
7751
|
+
return !("error" in registry) && registry.repos.includes(repo);
|
|
7752
|
+
},
|
|
7753
|
+
enroll: async (repo) => {
|
|
7754
|
+
const before = await loadProjectRegistry(registryPath);
|
|
7755
|
+
const enrolled = await updateRepos(registryPath, repos => addRepos(repos, [repo]));
|
|
7756
|
+
if (!enrolled.ok)
|
|
7757
|
+
return { ok: false, message: enrolled.message };
|
|
7758
|
+
store.upsertProject(repo, projectName(repo), clock());
|
|
7759
|
+
return { ok: true, added: "error" in before || !before.repos.includes(repo) };
|
|
7760
|
+
},
|
|
7761
|
+
checkConnection: seams.checkConnection ?? (agent => checker(agent)),
|
|
7762
|
+
});
|
|
7763
|
+
}
|
|
7663
7764
|
async function upCommand(flags, context) {
|
|
7664
7765
|
const { store, write, json, clock } = context;
|
|
7665
7766
|
const demoFence = refuseDemo(context, "up");
|
|
@@ -7786,7 +7887,10 @@ async function upCommand(flags, context) {
|
|
|
7786
7887
|
if (!reserved) {
|
|
7787
7888
|
return fail(write, json, "up", "port-busy", `port ${port} is taken — another \`up\` or \`serve\` may already be running; stop it, or pick a different --port`, EXIT.refused);
|
|
7788
7889
|
}
|
|
7789
|
-
|
|
7890
|
+
// Under a coding agent nothing is asked and no secret is printed, even in a real terminal: what is shown lands in
|
|
7891
|
+
// its transcript.
|
|
7892
|
+
const agentRunning = underAgent(context.upSeams?.env ?? process.env);
|
|
7893
|
+
const canPrompt = !json && !agentRunning && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
7790
7894
|
let runnerName = "";
|
|
7791
7895
|
let runnerToken = "";
|
|
7792
7896
|
let approver = null;
|
|
@@ -8099,6 +8203,12 @@ async function upCommand(flags, context) {
|
|
|
8099
8203
|
// 9. Readiness, then the ONE startup envelope / greeting (finding 11/20).
|
|
8100
8204
|
const approverPlan2 = approver;
|
|
8101
8205
|
const url = console_.url;
|
|
8206
|
+
const terminal = !agentRunning && (context.upSeams?.terminal ?? process.stdout.isTTY === true);
|
|
8207
|
+
const upHandoff = buildHandoff({
|
|
8208
|
+
url,
|
|
8209
|
+
login: { account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver, file: approverPlan2.passwordFile },
|
|
8210
|
+
});
|
|
8211
|
+
recordUpConsoleUrl(dirname(context.databaseFile), url, clock());
|
|
8102
8212
|
if (fatal === null) {
|
|
8103
8213
|
if (json) {
|
|
8104
8214
|
write(envelopeJson({
|
|
@@ -8111,8 +8221,17 @@ async function upCommand(flags, context) {
|
|
|
8111
8221
|
approvers: approverPlan2.approvers,
|
|
8112
8222
|
approverVerified: approverPlan2.verified,
|
|
8113
8223
|
...(approverPlan2.passwordFile === null ? {} : { passwordFile: approverPlan2.passwordFile }),
|
|
8224
|
+
handoff: upHandoff,
|
|
8114
8225
|
}));
|
|
8115
8226
|
}
|
|
8227
|
+
else if (!terminal) {
|
|
8228
|
+
// No person at a terminal: an agent started this. It gets the
|
|
8229
|
+
// handoff to relay, never the password, and no browser opens.
|
|
8230
|
+
write("");
|
|
8231
|
+
for (const line of handoffLines(upHandoff))
|
|
8232
|
+
write(line);
|
|
8233
|
+
write("Run `toolroll onboard` inside a repository to add it and install the Toolroll skill for your agent.");
|
|
8234
|
+
}
|
|
8116
8235
|
else {
|
|
8117
8236
|
write("");
|
|
8118
8237
|
write(`The console is on ${url}`);
|
|
@@ -8137,8 +8256,8 @@ async function upCommand(flags, context) {
|
|
|
8137
8256
|
write(" The inbox checklist shows what remains before approved work builds unattended.");
|
|
8138
8257
|
write(" Ctrl-C stops Toolroll on this machine.");
|
|
8139
8258
|
}
|
|
8140
|
-
if (!json && !flags.has("no-open") &&
|
|
8141
|
-
openBrowser(url);
|
|
8259
|
+
if (!json && !flags.has("no-open") && terminal)
|
|
8260
|
+
(context.upSeams?.openBrowser ?? openBrowser)(url);
|
|
8142
8261
|
}
|
|
8143
8262
|
// 10. Supervise to the end.
|
|
8144
8263
|
await stopped;
|
package/dist/prompt.d.ts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/** Whether asking is even possible: a human on both ends of the pipe. */
|
|
9
9
|
export declare function interactive(): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a coding agent is running this command. An agent's shell can be a
|
|
12
|
+
* real terminal, yet whatever is printed lands in its transcript and nobody
|
|
13
|
+
* types at the prompt, so it is treated like no terminal at all.
|
|
14
|
+
*/
|
|
15
|
+
export declare function underAgent(env?: Record<string, string | undefined>): boolean;
|
|
10
16
|
export declare function ask(question: string): Promise<string>;
|
|
11
17
|
/** Like ask, but what is typed never echoes — passwords live here. */
|
|
12
18
|
/**
|
package/dist/prompt.js
CHANGED
|
@@ -10,6 +10,18 @@ import { createInterface } from "node:readline";
|
|
|
10
10
|
export function interactive() {
|
|
11
11
|
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
12
12
|
}
|
|
13
|
+
/** Variables a coding agent sets for the commands it runs. CODEX_HOME and CODEX_API_KEY are a person's own settings. */
|
|
14
|
+
const AGENT_VARIABLES = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "GEMINI_CLI", "CURSOR_AGENT", "OPENCODE"];
|
|
15
|
+
const PERSON_CODEX_VARIABLES = new Set(["CODEX_HOME", "CODEX_API_KEY"]);
|
|
16
|
+
/**
|
|
17
|
+
* Whether a coding agent is running this command. An agent's shell can be a
|
|
18
|
+
* real terminal, yet whatever is printed lands in its transcript and nobody
|
|
19
|
+
* types at the prompt, so it is treated like no terminal at all.
|
|
20
|
+
*/
|
|
21
|
+
export function underAgent(env = process.env) {
|
|
22
|
+
return Object.entries(env).some(([name, value]) => value !== undefined && value !== ""
|
|
23
|
+
&& (AGENT_VARIABLES.includes(name) || (name.startsWith("CODEX_") && !PERSON_CODEX_VARIABLES.has(name))));
|
|
24
|
+
}
|
|
13
25
|
export function ask(question) {
|
|
14
26
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
15
27
|
return new Promise(resolve => rl.question(question, answer => {
|
package/dist/releases.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export declare const UPDATE_SWITCH_FILE = "update-check";
|
|
|
16
16
|
export declare const RUNNER_VERSIONS_FILE = "runner-versions.json";
|
|
17
17
|
export declare const CHECK_EVERY_MS: number;
|
|
18
18
|
export declare const CHECK_TIMEOUT_MS = 5000;
|
|
19
|
+
/** The npm registry `toolroll update` downloads releases from. */
|
|
20
|
+
export declare const REGISTRY = "https://registry.npmjs.org";
|
|
19
21
|
export declare const REGISTRY_URL = "https://registry.npmjs.org/toolroll/latest";
|
|
20
22
|
export declare const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
|
|
21
23
|
export type Release = {
|
|
@@ -74,3 +76,10 @@ export declare function runnerVersions(dir: string): RunnerVersion[];
|
|
|
74
76
|
export declare function startUpdateChecks(store: Store, dir: string, current: string, updateCommand: string, io?: ReleaseIo & {
|
|
75
77
|
everyMs?: number;
|
|
76
78
|
}): () => void;
|
|
79
|
+
/** The latest version, asked of npm now: for an explicit update (the command, Check now), not the once-a-day
|
|
80
|
+
* notice, so neither its cache nor its switch applies. Throws with npm's own reason. */
|
|
81
|
+
export declare function latestVersionNow(io?: {
|
|
82
|
+
fetch?: typeof fetch;
|
|
83
|
+
}): Promise<{
|
|
84
|
+
version: string;
|
|
85
|
+
}>;
|
package/dist/releases.js
CHANGED
|
@@ -18,7 +18,9 @@ export const UPDATE_SWITCH_FILE = "update-check";
|
|
|
18
18
|
export const RUNNER_VERSIONS_FILE = "runner-versions.json";
|
|
19
19
|
export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
20
20
|
export const CHECK_TIMEOUT_MS = 5_000;
|
|
21
|
-
|
|
21
|
+
/** The npm registry `toolroll update` downloads releases from. */
|
|
22
|
+
export const REGISTRY = "https://registry.npmjs.org";
|
|
23
|
+
export const REGISTRY_URL = `${REGISTRY}/toolroll/latest`;
|
|
22
24
|
export const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
|
|
23
25
|
const NOTES_API = "https://api.github.com/repos/ap9000/toolroll/releases/tags/";
|
|
24
26
|
const NOTES_MAX = 20_000;
|
|
@@ -223,3 +225,14 @@ function writeAtomic(file, content) {
|
|
|
223
225
|
writeFileSync(temporary, content, { mode: 0o600 });
|
|
224
226
|
renameSync(temporary, file);
|
|
225
227
|
}
|
|
228
|
+
/** The latest version, asked of npm now: for an explicit update (the command, Check now), not the once-a-day
|
|
229
|
+
* notice, so neither its cache nor its switch applies. Throws with npm's own reason. */
|
|
230
|
+
export async function latestVersionNow(io = {}) {
|
|
231
|
+
const answer = await (io.fetch ?? fetch)(REGISTRY_URL, { signal: AbortSignal.timeout(10_000), headers: { accept: "application/json" } });
|
|
232
|
+
if (!answer.ok)
|
|
233
|
+
throw new Error(`npm answered ${answer.status}`);
|
|
234
|
+
const version = (await answer.json()).version;
|
|
235
|
+
if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version))
|
|
236
|
+
throw new Error("npm did not name a version");
|
|
237
|
+
return { version };
|
|
238
|
+
}
|
package/dist/serve.d.ts
CHANGED
|
@@ -11,8 +11,10 @@ import { run as execRun } from "./exec.js";
|
|
|
11
11
|
import { type AgentChoice } from "./agentconfig.js";
|
|
12
12
|
import { type RouteProjection, type RouteOverride, type RouteStamp, type RiskLevel } from "./phase-routing.js";
|
|
13
13
|
import { type Phase } from "./provider.js";
|
|
14
|
+
import { launchRuntimeUpdate } from "./toolroll-update.js";
|
|
14
15
|
import type { RevisionLineage } from "./store.js";
|
|
15
16
|
import type { SubscriptionMateRunner } from "./subscription-chat.js";
|
|
17
|
+
import { type InstallMethod } from "./install-method.js";
|
|
16
18
|
export type ServeOptions = {
|
|
17
19
|
/** Tests: the bin whose real path says how Toolroll was installed (Settings → Updates' command). */
|
|
18
20
|
installBin?: string;
|
|
@@ -53,6 +55,18 @@ export type ServeOptions = {
|
|
|
53
55
|
configDir?: string;
|
|
54
56
|
/** Test seam for the Slack setup handshake. */
|
|
55
57
|
slackFetcher?: typeof fetch;
|
|
58
|
+
/** Settings → Updates seams: the latest release, how this Toolroll was
|
|
59
|
+
* installed, its version, and how the updater job starts. Tests and
|
|
60
|
+
* screenshots inject these; production reads the registry and launchd. */
|
|
61
|
+
updates?: {
|
|
62
|
+
latest?: () => Promise<{
|
|
63
|
+
version: string;
|
|
64
|
+
}>;
|
|
65
|
+
method?: InstallMethod;
|
|
66
|
+
current?: string;
|
|
67
|
+
dist?: string;
|
|
68
|
+
launch?: typeof launchRuntimeUpdate;
|
|
69
|
+
};
|
|
56
70
|
discordFetcher?: typeof fetch;
|
|
57
71
|
/** Injected by tests: Microsoft sign-in, key metadata and Teams conversation calls. */
|
|
58
72
|
teamsFetcher?: typeof fetch;
|
|
@@ -107,6 +121,8 @@ export type ServeOptions = {
|
|
|
107
121
|
additionalProjectRepos?: () => readonly string[];
|
|
108
122
|
connectionProbe?: typeof execRun;
|
|
109
123
|
connectionHome?: string;
|
|
124
|
+
/** Test seam for Chat's first tasks: the `gh` and `git grep` reads. */
|
|
125
|
+
firstTaskRunner?: typeof execRun;
|
|
110
126
|
modelCatalogFetcher?: typeof fetch;
|
|
111
127
|
/** Test seams for Settings → Models: CLI version probes and the PATH they search. */
|
|
112
128
|
modelRunner?: VersionRunner;
|