toolroll 0.8.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 +22 -0
- package/README.md +5 -5
- package/dist/agent-onboard.d.ts +41 -18
- package/dist/agent-onboard.js +129 -55
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +14 -13
- package/dist/daemon.d.ts +6 -2
- package/dist/daemon.js +6 -5
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +10 -7
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +4 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +64 -9
- package/dist/prompt.d.ts +6 -0
- package/dist/prompt.js +12 -0
- package/dist/serve.js +49 -23
- package/dist/store.d.ts +1 -1
- package/dist/store.js +5 -4
- package/dist/toolroll-update-cli.js +7 -3
- package/dist/toolroll-update-ui.js +6 -5
- package/dist/toolroll-update.d.ts +46 -12
- package/dist/toolroll-update.js +423 -99
- package/package.json +1 -1
package/dist/daemon.d.ts
CHANGED
|
@@ -148,8 +148,12 @@ export declare function installLaunchdService(definition: ServiceDefinition, run
|
|
|
148
148
|
/** Replace a file whole: a flushed temporary beside it, renamed over it, and the directory flushed, so a crash
|
|
149
149
|
* leaves the old text or the new, never a torn one. */
|
|
150
150
|
export declare function writeFileDurably(file: string, content: string | Uint8Array, mode?: number): void;
|
|
151
|
-
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again.
|
|
152
|
-
|
|
151
|
+
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again. `waitMs` is how long
|
|
152
|
+
* the job may take to leave launchd (5 s by default; a watch's `toolroll up` can take 45 s to exit). */
|
|
153
|
+
export declare function stopLaunchdService(definition: ServiceDefinition, run: SupervisorRunner, wait?: {
|
|
154
|
+
waitMs?: number;
|
|
155
|
+
sleep?: (ms: number) => Promise<void>;
|
|
156
|
+
}): Promise<{
|
|
153
157
|
ok: true;
|
|
154
158
|
wasLoaded: boolean;
|
|
155
159
|
} | {
|
package/dist/daemon.js
CHANGED
|
@@ -52,12 +52,12 @@ export function definitionDigest(unitContent) {
|
|
|
52
52
|
* appears to succeed, then the pending bootout removes the new job too. Wait
|
|
53
53
|
* for the observable supervisor boundary before loading the replacement.
|
|
54
54
|
*/
|
|
55
|
-
async function waitForLaunchdBootout(run, service, attempts = 50) {
|
|
55
|
+
async function waitForLaunchdBootout(run, service, attempts = 50, sleep = ms => new Promise(resolve => setTimeout(resolve, ms))) {
|
|
56
56
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
57
57
|
const status = await run("launchctl", ["print", service]);
|
|
58
58
|
if (status.code !== 0)
|
|
59
59
|
return true;
|
|
60
|
-
await
|
|
60
|
+
await sleep(100);
|
|
61
61
|
}
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
@@ -406,8 +406,9 @@ export function writeFileDurably(file, content, mode = 0o600) {
|
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
|
-
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again.
|
|
410
|
-
|
|
409
|
+
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again. `waitMs` is how long
|
|
410
|
+
* the job may take to leave launchd (5 s by default; a watch's `toolroll up` can take 45 s to exit). */
|
|
411
|
+
export async function stopLaunchdService(definition, run, wait = {}) {
|
|
411
412
|
const service = `gui/${launchdUid()}/${definition.label}`;
|
|
412
413
|
const disabled = await run("launchctl", ["disable", service]);
|
|
413
414
|
if (disabled.code !== 0)
|
|
@@ -415,7 +416,7 @@ export async function stopLaunchdService(definition, run) {
|
|
|
415
416
|
const modern = await run("launchctl", ["bootout", service]);
|
|
416
417
|
if (modern.code !== 0)
|
|
417
418
|
await run("launchctl", ["unload", definition.unitPath]);
|
|
418
|
-
if (!(await waitForLaunchdBootout(run, service)))
|
|
419
|
+
if (!(await waitForLaunchdBootout(run, service, Math.ceil((wait.waitMs ?? 5000) / 100), wait.sleep)))
|
|
419
420
|
return { ok: false, message: `launchctl did not stop ${definition.label}; the service is still loaded` };
|
|
420
421
|
return { ok: true, wasLoaded: modern.code === 0 };
|
|
421
422
|
}
|
package/dist/first-run.d.ts
CHANGED
|
@@ -46,8 +46,11 @@ export declare function firstTaskSuggestions(found: {
|
|
|
46
46
|
issues: readonly OpenIssue[];
|
|
47
47
|
todos: readonly TodoComment[];
|
|
48
48
|
}): FirstTaskSuggestion[];
|
|
49
|
-
/** `git grep`
|
|
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. */
|
|
50
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[];
|
|
51
54
|
/** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
|
|
52
55
|
export declare function findFirstTasks(repo: string, runner?: Runner): Promise<FirstTaskSuggestion[]>;
|
|
53
56
|
/** "First result in 7 min": how long after the installation began its first Ready result arrived. */
|
package/dist/first-run.js
CHANGED
|
@@ -42,24 +42,27 @@ export function firstTaskSuggestions(found) {
|
|
|
42
42
|
return [...fromIssues, ...fromTodos, ...GENERIC_FIRST_TASKS].slice(0, 3);
|
|
43
43
|
}
|
|
44
44
|
const SKIPPED = /(^|\/)(node_modules|dist|build|vendor|third_party|\.git)\//;
|
|
45
|
-
/** `git grep`
|
|
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. */
|
|
46
47
|
export function parseTodoLines(stdout, limit = 3) {
|
|
47
48
|
const found = [];
|
|
48
49
|
const seen = new Set();
|
|
49
|
-
for (const
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
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))
|
|
52
53
|
continue;
|
|
53
54
|
const note = /\b(?:TODO|FIXME)\b(?:\([^)]*\))?[:\s-]*(.*)$/.exec(match[3]);
|
|
54
55
|
if (note === null)
|
|
55
56
|
continue;
|
|
56
|
-
seen.add(
|
|
57
|
-
found.push({ file
|
|
57
|
+
seen.add(file);
|
|
58
|
+
found.push({ file, line: Number(match[2]), text: clip(note[1].replace(/\s*(\*\/|-->)\s*$/, "").trim(), 120) });
|
|
58
59
|
if (found.length === limit)
|
|
59
60
|
break;
|
|
60
61
|
}
|
|
61
62
|
return found;
|
|
62
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"];
|
|
63
66
|
/** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
|
|
64
67
|
export async function findFirstTasks(repo, runner = run) {
|
|
65
68
|
let issues = [];
|
|
@@ -74,7 +77,7 @@ export async function findFirstTasks(repo, runner = run) {
|
|
|
74
77
|
let todos = [];
|
|
75
78
|
if (issues.length < 3) {
|
|
76
79
|
try {
|
|
77
|
-
const result = await runner("git", [
|
|
80
|
+
const result = await runner("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
|
|
78
81
|
if (result.code === 0 || result.stdout !== "")
|
|
79
82
|
todos = parseTodoLines(result.stdout);
|
|
80
83
|
}
|
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" | "managed";
|
|
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
|
@@ -15,6 +15,8 @@ export const UPDATE_COMMANDS = {
|
|
|
15
15
|
source: "git pull && npm install && npm run build",
|
|
16
16
|
// A runtime `toolroll update` installed (staged-upgrades/release-* or rollback-*): it updates itself.
|
|
17
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",
|
|
18
20
|
};
|
|
19
21
|
/** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
|
|
20
22
|
export function installMethod(bin = process.argv[1], probe = {}) {
|
|
@@ -36,6 +38,8 @@ function installKind(bin, probe) {
|
|
|
36
38
|
// by deploying from source, never from npm.
|
|
37
39
|
if (path.includes("/staged-upgrades/browser-"))
|
|
38
40
|
return "source";
|
|
41
|
+
if (/\.app\/Contents\/Resources\//.test(path))
|
|
42
|
+
return "desktop";
|
|
39
43
|
if (/\/staged-upgrades\/(release|rollback)-/.test(path))
|
|
40
44
|
return "managed";
|
|
41
45
|
if (/\/Cellar\/toolroll\//i.test(path))
|
package/dist/operate.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export type OperateOptions = {
|
|
|
72
72
|
/** Injected by tests: whether `up` has a person at a terminal, and how it opens a browser. */
|
|
73
73
|
upSeams?: {
|
|
74
74
|
terminal?: boolean;
|
|
75
|
+
env?: Record<string, string | undefined>;
|
|
75
76
|
openBrowser?: (url: string) => void;
|
|
76
77
|
};
|
|
77
78
|
};
|
|
@@ -115,6 +116,7 @@ export declare function runOperate(command: string, argv: readonly string[], wri
|
|
|
115
116
|
* finding 27): exclusive, 0600, fsynced — file and directory both — so the
|
|
116
117
|
* row only ever follows a durable secret. Throws on any failure. */
|
|
117
118
|
export declare function writeLoginFileDurably(path: string, name: string, password: string): void;
|
|
119
|
+
export declare function readUpConsoleUrl(dir: string): string | null;
|
|
118
120
|
/**
|
|
119
121
|
* The people allowed to agree to a scope.
|
|
120
122
|
*
|
package/dist/operate.js
CHANGED
|
@@ -74,7 +74,7 @@ import { sweepLiveLogs } from "./live.js";
|
|
|
74
74
|
import { configPath, addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
|
|
75
75
|
import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
|
|
76
76
|
import { pushPass } from "./push.js";
|
|
77
|
-
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";
|
|
78
78
|
import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
|
|
79
79
|
import { createServer as createNetServer } from "node:net";
|
|
80
80
|
import { spawn as spawnChild } from "node:child_process";
|
|
@@ -128,7 +128,7 @@ import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLeg
|
|
|
128
128
|
import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
|
|
129
129
|
import { scanRepo } from "./capscan.js";
|
|
130
130
|
import { computeGaps, describeCapability } from "./gaps.js";
|
|
131
|
-
import { ask, askHidden, confirm, interactive } from "./prompt.js";
|
|
131
|
+
import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
|
|
132
132
|
import { runMateCli, answerContextLines } from "./mate-cli.js";
|
|
133
133
|
import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./mate-doors.js";
|
|
134
134
|
import { verifyApproverByPassword } from "./principal.js";
|
|
@@ -7665,6 +7665,38 @@ function openBrowser(url) {
|
|
|
7665
7665
|
// A browser that will not open is a URL the greeting already printed.
|
|
7666
7666
|
}
|
|
7667
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
|
+
}
|
|
7668
7700
|
/**
|
|
7669
7701
|
* `onboard`: the agent that installed Toolroll becomes its lead. The work
|
|
7670
7702
|
* lives in agent-onboard.ts; this wires it to the installation: the same
|
|
@@ -7679,8 +7711,11 @@ async function onboardCommand(flags, context) {
|
|
|
7679
7711
|
if (portGiven !== undefined && (!Number.isInteger(port) || port < 1 || port >= 65536)) {
|
|
7680
7712
|
return fail(write, json, "onboard", "usage", "--port is a whole number under 65536", EXIT.usage);
|
|
7681
7713
|
}
|
|
7714
|
+
// The console where `up` last served, unless --port says otherwise.
|
|
7715
|
+
const served = portGiven === undefined ? readUpConsoleUrl(dirname(context.databaseFile)) : null;
|
|
7682
7716
|
const home = seams.home ?? homedir();
|
|
7683
7717
|
const env = seams.env ?? process.env;
|
|
7718
|
+
const loginFile = join(dirname(context.databaseFile), UP_LOGIN_FILE);
|
|
7684
7719
|
const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
|
|
7685
7720
|
const registryPath = registryPathOf(context);
|
|
7686
7721
|
const checker = createConnectionChecker({ home, env });
|
|
@@ -7690,18 +7725,31 @@ async function onboardCommand(flags, context) {
|
|
|
7690
7725
|
yes: flag(flags, "yes"),
|
|
7691
7726
|
remove: flag(flags, "remove"),
|
|
7692
7727
|
agentFlag: text(flags, "agent"),
|
|
7693
|
-
url: `http://127.0.0.1:${port}`,
|
|
7694
|
-
|
|
7728
|
+
url: served ?? `http://127.0.0.1:${port}`,
|
|
7729
|
+
login: onboardLogin(store, loginFile),
|
|
7695
7730
|
home,
|
|
7731
|
+
worktrees: join(dirname(context.databaseFile), "worktrees"),
|
|
7696
7732
|
env,
|
|
7697
7733
|
version: PACKAGE_VERSION,
|
|
7698
7734
|
cwd: seams.cwd ?? process.cwd(),
|
|
7699
|
-
|
|
7735
|
+
// An agent's shell may be a terminal, but nobody answers y/N there.
|
|
7736
|
+
interactive: seams.interactive ?? (interactive() && !underAgent(env)),
|
|
7700
7737
|
confirm: seams.confirm ?? (async (question) => /^y(es)?$/i.test(await ask(`${question} `))),
|
|
7701
7738
|
findRepo: seams.findRepo ?? (async (cwd) => {
|
|
7702
7739
|
const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
7703
|
-
|
|
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 };
|
|
7704
7748
|
}),
|
|
7749
|
+
enrolled: async (repo) => {
|
|
7750
|
+
const registry = await loadProjectRegistry(registryPath);
|
|
7751
|
+
return !("error" in registry) && registry.repos.includes(repo);
|
|
7752
|
+
},
|
|
7705
7753
|
enroll: async (repo) => {
|
|
7706
7754
|
const before = await loadProjectRegistry(registryPath);
|
|
7707
7755
|
const enrolled = await updateRepos(registryPath, repos => addRepos(repos, [repo]));
|
|
@@ -7839,7 +7887,10 @@ async function upCommand(flags, context) {
|
|
|
7839
7887
|
if (!reserved) {
|
|
7840
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);
|
|
7841
7889
|
}
|
|
7842
|
-
|
|
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;
|
|
7843
7894
|
let runnerName = "";
|
|
7844
7895
|
let runnerToken = "";
|
|
7845
7896
|
let approver = null;
|
|
@@ -8152,8 +8203,12 @@ async function upCommand(flags, context) {
|
|
|
8152
8203
|
// 9. Readiness, then the ONE startup envelope / greeting (finding 11/20).
|
|
8153
8204
|
const approverPlan2 = approver;
|
|
8154
8205
|
const url = console_.url;
|
|
8155
|
-
const terminal = context.upSeams?.terminal ?? process.stdout.isTTY === true;
|
|
8156
|
-
const upHandoff = buildHandoff({
|
|
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());
|
|
8157
8212
|
if (fatal === null) {
|
|
8158
8213
|
if (json) {
|
|
8159
8214
|
write(envelopeJson({
|
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/serve.js
CHANGED
|
@@ -1883,7 +1883,7 @@ export function createDecisionServer(options) {
|
|
|
1883
1883
|
})),
|
|
1884
1884
|
cancelledBlockers: cancelled,
|
|
1885
1885
|
gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
|
|
1886
|
-
wizard:
|
|
1886
|
+
wizard: firstRunStepsNow(now),
|
|
1887
1887
|
worker: (() => {
|
|
1888
1888
|
// The one fact the inbox must never hide (install review): with
|
|
1889
1889
|
// no worker answering, nothing here will ever build, and every
|
|
@@ -3377,7 +3377,7 @@ export function createDecisionServer(options) {
|
|
|
3377
3377
|
}
|
|
3378
3378
|
}
|
|
3379
3379
|
// The first run belongs to the lead conversation, for someone who can act on it.
|
|
3380
|
-
const firstRun = focusTask === null && who.role === "approver" ?
|
|
3380
|
+
const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
|
|
3381
3381
|
const withFirstRun = (shown) => firstRun === undefined ? shown : { ...shown, workspace: { ...shown.workspace, firstRun } };
|
|
3382
3382
|
if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
|
|
3383
3383
|
{
|
|
@@ -3865,38 +3865,64 @@ export function createDecisionServer(options) {
|
|
|
3865
3865
|
* review, finding 14). Each step is either something this console can do,
|
|
3866
3866
|
* or the exact command where only the CLI can (finding e).
|
|
3867
3867
|
*/
|
|
3868
|
-
|
|
3868
|
+
function firstRunStepsNow(now) {
|
|
3869
3869
|
if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
|
|
3870
3870
|
return null;
|
|
3871
|
-
return firstRunSteps({ agentSignedIn:
|
|
3871
|
+
return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
|
|
3872
3872
|
}
|
|
3873
|
-
/**
|
|
3874
|
-
*
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3873
|
+
/**
|
|
3874
|
+
* Whether a coding agent is signed in where the work runs. This machine's
|
|
3875
|
+
* own checks (the ones Settings makes) speak only for a worker on this
|
|
3876
|
+
* machine; a worker elsewhere speaks through its own readiness report, and
|
|
3877
|
+
* the console never claims to have checked that machine (finding 15).
|
|
3878
|
+
* Nothing here waits on a probe: this machine's answer is the last one
|
|
3879
|
+
* seen, refreshed in the background.
|
|
3880
|
+
*/
|
|
3881
|
+
function agentSignedIn(now) {
|
|
3882
|
+
const here = hostname();
|
|
3883
|
+
const workers = store.listRunners().filter(one => one.retiredAt === null && runnerAlive(one, now));
|
|
3884
|
+
if ((options.localRunner !== undefined || workers.some(one => one.host === here)) && localAgentSignedIn())
|
|
3885
|
+
return true;
|
|
3886
|
+
return workers.some(one => one.host !== here && store.providerReadiness(one.name).some(seen => seen.state === "ready"));
|
|
3887
|
+
}
|
|
3888
|
+
let localSignIn = null;
|
|
3889
|
+
let localSignInChecking = false;
|
|
3890
|
+
function localAgentSignedIn() {
|
|
3891
|
+
if (!localSignInChecking && (localSignIn === null || Date.now() - localSignIn.at > 30_000)) {
|
|
3892
|
+
localSignInChecking = true;
|
|
3893
|
+
void Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")))
|
|
3894
|
+
.then(states => { localSignIn = { at: Date.now(), signedIn: states.some(state => state === "connected" || state === "key-works" || state === "key-present") }; })
|
|
3895
|
+
.catch(() => { })
|
|
3896
|
+
.finally(() => { localSignInChecking = false; });
|
|
3897
|
+
}
|
|
3898
|
+
return localSignIn?.signedIn ?? false;
|
|
3899
|
+
}
|
|
3900
|
+
/** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
|
|
3901
|
+
* every ten minutes. A render never waits on `gh` or `git grep`. */
|
|
3881
3902
|
const firstTasks = new Map();
|
|
3882
3903
|
function firstTasksFor(repo) {
|
|
3883
|
-
const at = Date.now()
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3904
|
+
const at = Date.now();
|
|
3905
|
+
let entry = firstTasks.get(repo);
|
|
3906
|
+
if (entry === undefined || (entry.until <= at && !entry.reading)) {
|
|
3907
|
+
const reading = { until: at + 10 * 60_000, found: entry?.found ?? null, reading: true };
|
|
3908
|
+
firstTasks.set(repo, reading);
|
|
3909
|
+
entry = reading;
|
|
3910
|
+
void findFirstTasks(repo, options.firstTaskRunner ?? execRun)
|
|
3911
|
+
.then(found => { reading.found = found; }, () => { })
|
|
3912
|
+
.finally(() => { reading.reading = false; });
|
|
3913
|
+
}
|
|
3914
|
+
return entry.found ?? firstTaskSuggestions({ issues: [], todos: [] });
|
|
3889
3915
|
}
|
|
3890
3916
|
/** Chat's first run: the steps, first tasks until the first task exists, and the sandbox while no agent is signed in. */
|
|
3891
|
-
|
|
3892
|
-
const steps =
|
|
3917
|
+
function chatFirstRun(now, project) {
|
|
3918
|
+
const steps = firstRunStepsNow(now);
|
|
3893
3919
|
if (steps === null)
|
|
3894
3920
|
return undefined;
|
|
3895
3921
|
const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
|
|
3896
3922
|
const repo = project ?? managedRepos()[0] ?? null;
|
|
3897
3923
|
return {
|
|
3898
3924
|
steps,
|
|
3899
|
-
suggestions: taskFiled || repo === null ? [] :
|
|
3925
|
+
suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
|
|
3900
3926
|
sandbox: steps.find(one => one.key === "agent")?.done === false ? SANDBOX_COMMAND : null,
|
|
3901
3927
|
};
|
|
3902
3928
|
}
|
|
@@ -6555,8 +6581,8 @@ export function createDecisionServer(options) {
|
|
|
6555
6581
|
if (!newerThan(version, current))
|
|
6556
6582
|
return back("problem", `Toolroll ${version} is not newer than ${current}. To go back, run toolroll update --rollback.`);
|
|
6557
6583
|
const method = options.updates?.method ?? installMethod();
|
|
6558
|
-
if (method.kind === "npx" || method.kind === "source")
|
|
6559
|
-
return back("problem", method.kind === "npx" ? "npx runs the latest release each time; there is nothing to update." : "This runs from a source checkout. Update it with git.");
|
|
6584
|
+
if (method.kind === "npx" || method.kind === "source" || method.kind === "desktop")
|
|
6585
|
+
return back("problem", method.kind === "npx" ? "npx runs the latest release each time; there is nothing to update." : method.kind === "desktop" ? "Update it from the Toolroll app." : "This runs from a source checkout. Update it with git.");
|
|
6560
6586
|
const before = runtimeUpdateStatus(stateDir).journal;
|
|
6561
6587
|
if (before !== null && !runtimeUpdateTerminal(before.phase))
|
|
6562
6588
|
return back("problem", "An update is already under way.");
|
package/dist/store.d.ts
CHANGED
|
@@ -4337,7 +4337,7 @@ export declare class Store {
|
|
|
4337
4337
|
* createConsoleTask. Set-once: an already-stamped row never changes. */
|
|
4338
4338
|
stampFiledVia(refId: number, via: string): void;
|
|
4339
4339
|
/**
|
|
4340
|
-
* When this installation first finished a run
|
|
4340
|
+
* When this installation first finished a run with a change — the
|
|
4341
4341
|
* moment the first-run checklist retires, permanently. Derived from run
|
|
4342
4342
|
* history the first time it is observed, then stamped as an append-only
|
|
4343
4343
|
* installation fact so later pruning of those rows cannot resurrect the
|
package/dist/store.js
CHANGED
|
@@ -14391,7 +14391,7 @@ export class Store {
|
|
|
14391
14391
|
this.db.prepare("UPDATE task_ref SET filed_via = ? WHERE id = ? AND filed_via IS NULL").run(via, refId);
|
|
14392
14392
|
}
|
|
14393
14393
|
/**
|
|
14394
|
-
* When this installation first finished a run
|
|
14394
|
+
* When this installation first finished a run with a change — the
|
|
14395
14395
|
* moment the first-run checklist retires, permanently. Derived from run
|
|
14396
14396
|
* history the first time it is observed, then stamped as an append-only
|
|
14397
14397
|
* installation fact so later pruning of those rows cannot resurrect the
|
|
@@ -14402,7 +14402,7 @@ export class Store {
|
|
|
14402
14402
|
if (fact !== null)
|
|
14403
14403
|
return fact;
|
|
14404
14404
|
const row = this.db
|
|
14405
|
-
.prepare("SELECT finished_at FROM run WHERE outcome
|
|
14405
|
+
.prepare("SELECT finished_at FROM run WHERE outcome = 'built' AND finished_at IS NOT NULL ORDER BY id LIMIT 1")
|
|
14406
14406
|
.get();
|
|
14407
14407
|
if (row === undefined)
|
|
14408
14408
|
return null;
|
|
@@ -15938,8 +15938,9 @@ export class Store {
|
|
|
15938
15938
|
this.recordRunProcessExits(id, result.now);
|
|
15939
15939
|
this.settleRunStop(id, result.stopSettlement ?? "finished", result.now);
|
|
15940
15940
|
// The first Ready result is an installation fact the moment it lands,
|
|
15941
|
-
// not only when something later looks for it.
|
|
15942
|
-
|
|
15941
|
+
// not only when something later looks for it. A run that changed
|
|
15942
|
+
// nothing is not a first result.
|
|
15943
|
+
if (result.outcome === "built")
|
|
15943
15944
|
this.firstSuccessAt(result.now);
|
|
15944
15945
|
// The park rate is *measured* — parked over concluded builder attempts —
|
|
15945
15946
|
// and maintained where attempts conclude, because the attention budget's
|
|
@@ -11,7 +11,7 @@ import { installMethod } from "./install-method.js";
|
|
|
11
11
|
import { isNewer, latestVersionNow } from "./releases.js";
|
|
12
12
|
import { databasePath } from "./store.js";
|
|
13
13
|
import { PACKAGE_VERSION } from "./version.js";
|
|
14
|
-
import { currentRuntime, machineSystem, readRuntimeUpdate, requestRuntimeUpdateCancel, resumeRuntimeUpdate, retireUpdateJob,
|
|
14
|
+
import { currentRuntime, lastCompletedUpdate, machineSystem, readRuntimeUpdate, requestRuntimeUpdateCancel, resumeRuntimeUpdate, retireUpdateJob, startRuntimeRollback, startRuntimeUpdate, } from "./toolroll-update.js";
|
|
15
15
|
/** Loaded on first use (as backup.ts and store.ts do), so modules that only import this one (the console,
|
|
16
16
|
* and tests that load it in a browser-like environment) never need `node:sqlite` itself. */
|
|
17
17
|
function sqlite() {
|
|
@@ -90,9 +90,13 @@ export async function runUpdateCommand(args, write, deps = {}) {
|
|
|
90
90
|
write("This Toolroll runs from a source checkout. Update it with git; toolroll update replaces installed releases only.");
|
|
91
91
|
return 1;
|
|
92
92
|
}
|
|
93
|
+
if (method.kind === "desktop") {
|
|
94
|
+
write("This Toolroll is the Toolroll app, which updates as a whole app. Update it from the Toolroll app.");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
93
97
|
if (has("rollback")) {
|
|
94
|
-
const last =
|
|
95
|
-
if (!last
|
|
98
|
+
const last = lastCompletedUpdate(stateDir);
|
|
99
|
+
if (!last) {
|
|
96
100
|
write("There is no completed update to roll back.");
|
|
97
101
|
return 1;
|
|
98
102
|
}
|
|
@@ -59,11 +59,12 @@ export function updatesHtml(view, notice) {
|
|
|
59
59
|
const newer = latest !== null && newerThan(latest, view.current);
|
|
60
60
|
const state = view.method.kind === "npx" ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">npx runs the latest release each time, so this is current.</p>`
|
|
61
61
|
: view.method.kind === "source" ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">This runs from a source checkout. Update it with git.</p>`
|
|
62
|
-
: newer ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}.
|
|
63
|
-
:
|
|
64
|
-
:
|
|
65
|
-
: `<h2>Toolroll ${e(view.current)}
|
|
66
|
-
|
|
62
|
+
: newer && view.method.kind === "desktop" ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}. Update it from the Toolroll app.</p>`
|
|
63
|
+
: newer ? `<h2>Toolroll ${e(latest)} is available</h2><p class="meta">You have ${e(view.current)}. Running work finishes first, and your current version is kept so you can go back.</p>`
|
|
64
|
+
: "off" in view.latest ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Update checks are off.</p><form method="get" action="/settings/updates" class="update-actions"><input type="hidden" name="check" value="now"><button type="submit">Check now</button></form>`
|
|
65
|
+
: latest === null ? `<h2>Toolroll ${e(view.current)}</h2><p class="meta">Couldn’t check for a newer release: ${e("problem" in view.latest ? view.latest.problem : "")}</p>`
|
|
66
|
+
: `<h2>Toolroll ${e(view.current)} is up to date</h2>`;
|
|
67
|
+
const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
|
|
67
68
|
? `<form method="post" action="/settings/updates">${csrf}<input type="hidden" name="version" value="${e(latest)}">` +
|
|
68
69
|
`<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
|
|
69
70
|
`<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button>` +
|