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/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
- export declare function stopLaunchdService(definition: ServiceDefinition, run: SupervisorRunner): Promise<{
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 new Promise(resolve => setTimeout(resolve, 100));
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
- export async function stopLaunchdService(definition, run) {
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
  }
@@ -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` lines ("file:line:text") to TODO or FIXME notes, one per file. */
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` lines ("file:line:text") to TODO or FIXME notes, one per file. */
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 line of stdout.split("\n")) {
50
- const match = /^(.+?):(\d+):(.*)$/.exec(line);
51
- if (match === null || SKIPPED.test(match[1]) || seen.has(match[1]))
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(match[1]);
57
- found.push({ file: match[1], line: Number(match[2]), text: clip(note[1].replace(/\s*(\*\/|-->)\s*$/, "").trim(), 120) });
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", ["grep", "-n", "-I", "-w", "--max-count=1", "-E", "TODO|FIXME"], { cwd: repo, timeoutMs: 5_000 });
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
  }
@@ -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;
@@ -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
- loginFile: join(dirname(context.databaseFile), UP_LOGIN_FILE),
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
- interactive: seams.interactive ?? interactive(),
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
- return top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
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
- const canPrompt = !json && process.stdin.isTTY === true && process.stdout.isTTY === true;
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({ url, loginFile: approverPlan2.passwordFile, account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver });
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: await firstRunStepsNow(now),
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" ? await chatFirstRun(now, chatProject) : undefined;
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
- async function firstRunStepsNow(now) {
3868
+ function firstRunStepsNow(now) {
3869
3869
  if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
3870
3870
  return null;
3871
- return firstRunSteps({ agentSignedIn: await agentSignedIn(), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
3871
+ return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
3872
3872
  }
3873
- /** Whether any coding agent on this computer is signed in: the same
3874
- * non-spending checks Settings makes, cached briefly. */
3875
- async function agentSignedIn() {
3876
- const states = await Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")));
3877
- return states.some(state => state === "connected" || state === "key-works" || state === "key-present");
3878
- }
3879
- /** First tasks per project, read at most every ten minutes: a `gh`
3880
- * round trip and a `git grep` never run on every render. */
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(), hit = firstTasks.get(repo);
3884
- if (hit !== undefined && hit.until > at)
3885
- return hit.value;
3886
- const value = findFirstTasks(repo, options.firstTaskRunner ?? execRun).catch(() => firstTaskSuggestions({ issues: [], todos: [] }));
3887
- firstTasks.set(repo, { until: at + 10 * 60_000, value });
3888
- return value;
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
- async function chatFirstRun(now, project) {
3892
- const steps = await firstRunStepsNow(now);
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 ? [] : await firstTasksFor(repo),
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 successfully — the
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 successfully — the
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 IN ('built','no-change') AND finished_at IS NOT NULL ORDER BY id LIMIT 1")
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
- if (result.outcome === "built" || result.outcome === "no-change")
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, runtimeUpdateTerminal, startRuntimeRollback, startRuntimeUpdate, } from "./toolroll-update.js";
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 = readRuntimeUpdate(stateDir);
95
- if (!last || last.kind !== "update" || last.phase !== "complete" || !runtimeUpdateTerminal(last.phase)) {
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)}. Running work finishes first, and your current version is kept so you can go back.</p>`
63
- : "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>`
64
- : 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>`
65
- : `<h2>Toolroll ${e(view.current)} is up to date</h2>`;
66
- const form = newer && !["npx", "source"].includes(view.method.kind)
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>` +