toolroll 0.9.83 → 0.9.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -3
  3. package/dist/assignment-ui.js +5 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +22 -22
  6. package/dist/browser-crew.js +5 -1
  7. package/dist/browser-workspace.d.ts +7 -0
  8. package/dist/contracts/console-api.d.ts +2 -1
  9. package/dist/contracts/console-api.js +5 -1
  10. package/dist/control-setup.d.ts +9 -0
  11. package/dist/control-setup.js +31 -4
  12. package/dist/control-ui.d.ts +7 -1
  13. package/dist/control-ui.js +3 -2
  14. package/dist/desktop-update.d.ts +1 -1
  15. package/dist/dispatch.d.ts +15 -0
  16. package/dist/dispatch.js +26 -4
  17. package/dist/first-run.d.ts +5 -2
  18. package/dist/first-run.js +13 -2
  19. package/dist/folder-look.d.ts +3 -0
  20. package/dist/folder-look.js +8 -4
  21. package/dist/guides.js +4 -3
  22. package/dist/model-catalog.d.ts +5 -0
  23. package/dist/model-catalog.js +11 -2
  24. package/dist/model-choices.d.ts +188 -0
  25. package/dist/model-choices.js +405 -0
  26. package/dist/model-names.d.ts +6 -0
  27. package/dist/model-names.js +34 -0
  28. package/dist/models-ui.d.ts +69 -5
  29. package/dist/models-ui.js +53 -10
  30. package/dist/needs-you.d.ts +8 -0
  31. package/dist/needs-you.js +8 -0
  32. package/dist/operate.d.ts +21 -0
  33. package/dist/operate.js +96 -26
  34. package/dist/project-admission.d.ts +4 -1
  35. package/dist/project-admission.js +23 -2
  36. package/dist/project-identity.d.ts +9 -2
  37. package/dist/project-identity.js +80 -33
  38. package/dist/project-knowledge.d.ts +6 -6
  39. package/dist/serve.js +60 -13
  40. package/dist/server/chat.js +5 -4
  41. package/dist/server/chrome.d.ts +3 -0
  42. package/dist/server/chrome.js +5 -2
  43. package/dist/server/console.css +5 -2
  44. package/dist/server/flows.js +4 -0
  45. package/dist/server/http.d.ts +1 -0
  46. package/dist/server/http.js +6 -0
  47. package/dist/server/pages.js +42 -6
  48. package/dist/server/people-tokens.js +40 -20
  49. package/dist/server/render-pages.d.ts +10 -0
  50. package/dist/server/render-pages.js +29 -14
  51. package/dist/server/render-people.d.ts +14 -2
  52. package/dist/server/render-people.js +35 -12
  53. package/dist/server/render-settings.js +1 -0
  54. package/dist/server/render-tasks.js +7 -14
  55. package/dist/server/request-context.d.ts +2 -0
  56. package/dist/server/route-table.js +8 -1
  57. package/dist/server/settings.d.ts +58 -1
  58. package/dist/server/settings.js +198 -69
  59. package/dist/server/signup-gate.d.ts +9 -0
  60. package/dist/server/signup-gate.js +40 -0
  61. package/dist/server/tasks.js +12 -2
  62. package/dist/settings-kit-script.d.ts +21 -0
  63. package/dist/settings-kit-script.js +67 -0
  64. package/dist/settings-kit.d.ts +137 -0
  65. package/dist/settings-kit.js +186 -0
  66. package/dist/settings-proofs.d.ts +64 -0
  67. package/dist/settings-proofs.js +101 -0
  68. package/dist/setup-guide.js +4 -2
  69. package/dist/spend-report.d.ts +87 -0
  70. package/dist/spend-report.js +265 -0
  71. package/dist/spend-ui.d.ts +29 -8
  72. package/dist/spend-ui.js +74 -39
  73. package/dist/task-sizing.d.ts +5 -3
  74. package/dist/task-sizing.js +4 -4
  75. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { chatControlHref, chatResultHref } from './chat-controls.js';
2
+ import { startBuilderHref } from './needs-you.js';
2
3
  /** The index is navigation only. Controls retain their exact owning task, run,
3
4
  * decision and approval ceremony; opening a result keeps its saved execution. */
4
5
  export function browserWorkActionHref(item) {
@@ -25,13 +26,16 @@ export function browserWorkActionHref(item) {
25
26
  }
26
27
  if (runId !== null && (action.code === 'inspect-run' || action.code === 'reconcile-run'))
27
28
  return `/r/${runId}`;
29
+ // The task page only repeats that no builder is connected: lead to the command that connects one.
30
+ if (action.code === 'start-worker')
31
+ return startBuilderHref(taskId);
28
32
  const anchor = action.code === 'approve-scope' ? '#approve'
29
33
  : action.code === 'inspect-stop' || action.code === 'resume-run' ? '#task-control'
30
34
  // Retry (and Build again) is the task's status card itself, at the top: no anchor to its options.
31
35
  : action.code === 'unhold' ? '#task-actions'
32
36
  : action.code === 'write-scope' || action.code === 'select-agent' ? '#scope'
33
37
  : action.code === 'inspect-hold' ? '#holds'
34
- : action.code === 'start-worker' || action.code === 'repair-dependency' ? '#run-status' : '';
38
+ : action.code === 'repair-dependency' ? '#run-status' : '';
35
39
  return `/t/${encodeURIComponent(item.rootId)}?version=${encodeURIComponent(taskId)}` + anchor;
36
40
  }
37
41
  /** Render an already-admitted page without repeating its database query. Every task reads its one headline, the
@@ -1464,6 +1464,13 @@ export type BrowserWorkspace = {
1464
1464
  sensitive: boolean;
1465
1465
  /** Passwordless is on for this signed-in browser: ceremony password fields are left out (docs/plans/passwordless.md). */
1466
1466
  passwordless?: boolean;
1467
+ /** The session's open project (its path), or null: the sidebar's project picker names it. */
1468
+ openProject?: string | null;
1469
+ /** The settings frame's breadcrumb ("Settings / <page>"; settings-kit.ts): the header shows it as links. */
1470
+ crumbs?: {
1471
+ label: string;
1472
+ href: string | null;
1473
+ }[];
1467
1474
  /** What this person named their lead (Settings → Lead): the chat header and every lead message say it. */
1468
1475
  leadName?: string;
1469
1476
  refreshUrl: string;
@@ -106,7 +106,8 @@ export declare const CONSOLE_FORMS: {
106
106
  readonly toolsConnect: FormContract<"access" | "also" | "kit" | "password" | "repo" | "service" | "shown" | "template">;
107
107
  readonly toolsChange: FormContract<"action" | "catalog" | "import" | "name" | "password" | "repo" | "secret" | "secrets" | "shown" | "target" | "transport" | "value">;
108
108
  readonly appearance: FormContract<"accent" | "quiet" | "return" | "theme">;
109
- readonly models: FormContract<"agent" | "enabled" | "phase" | "tool">;
109
+ readonly models: FormContract<"enabled" | "tool">;
110
+ readonly modelsChange: FormContract<"build" | "build-light" | "build-strong" | "change" | "digest" | "password" | "plan" | "plan-strong" | "preset" | "repair" | "review" | "review-strong" | "scope" | "switch" | "to">;
110
111
  readonly knowledgeRefresh: FormContract<"repo">;
111
112
  readonly knowledgeProposal: FormContract<"decision" | "proposal" | "repo">;
112
113
  readonly knowledgeDecision: FormContract<"action" | "claim" | "decision" | "reason" | "repo" | "why">;
@@ -115,7 +115,11 @@ export const CONSOLE_FORMS = {
115
115
  toolsConnect: formContract("POST /settings/tools/connect", ["repo", "also", "service", "template", "kit", "shown", "password", "access"]),
116
116
  toolsChange: formContract("POST /settings/tools/change", ["repo", "shown", "action", "name", "password", "catalog", "transport", "target", "secrets", "import", "secret", "value"]),
117
117
  appearance: formContract("POST /settings/appearance", ["accent", "quiet", "return", "theme"]),
118
- models: formContract("POST /settings/models/check, /settings/models/watch, /settings/models/update, /settings/models/agent", ["enabled", "tool", "phase", "agent"]),
118
+ models: formContract("POST /settings/models/check, /settings/models/watch, /settings/models/update", ["enabled", "tool"]),
119
+ // The one Models editor (docs/plans/spend-dashboard.md slice 3): every role at a scope in one save, or a previewed
120
+ // preset or switch; the digest is what the page showed, and the change digest what the preview showed.
121
+ modelsChange: formContract("POST /settings/models/agent, /settings/models/apply", ["password", "scope", "digest", "change", "preset", "switch", "to",
122
+ "plan", "plan-strong", "build-light", "build", "build-strong", "repair", "review", "review-strong"]),
119
123
  knowledgeRefresh: formContract("POST /settings/knowledge/refresh", ["repo"]),
120
124
  knowledgeProposal: formContract("POST /settings/knowledge/proposal", ["repo", "decision", "proposal"]),
121
125
  knowledgeDecision: formContract("POST /settings/knowledge/decision", ["repo", "action", "claim", "why", "decision", "reason"]),
@@ -13,6 +13,7 @@ export declare function previewSetup(store: Store, repo: string, inputs: SetupIn
13
13
  seconds?: never;
14
14
  provider?: never;
15
15
  model?: never;
16
+ models?: never;
16
17
  } | {
17
18
  message?: never;
18
19
  ok: true;
@@ -21,6 +22,13 @@ export declare function previewSetup(store: Store, repo: string, inputs: SetupIn
21
22
  seconds: number;
22
23
  provider: "claude" | "cline" | "codex" | "cursor" | "gemini" | "goose" | "opencode" | "openrouter" | "qwen";
23
24
  model: string;
25
+ models: {
26
+ rows: (import("./model-choices.js").DiffRow & {
27
+ inherited: import("./model-choices.js").Pair | null;
28
+ })[];
29
+ state: string;
30
+ change: string;
31
+ } | null;
24
32
  };
25
33
  export declare function approveSetup(store: Store, repo: string, inputs: SetupInputs, fingerprint: string, by: string, token: string, now: Date): {
26
34
  ok: false;
@@ -30,6 +38,7 @@ export declare function approveSetup(store: Store, repo: string, inputs: SetupIn
30
38
  seconds?: never;
31
39
  provider?: never;
32
40
  model?: never;
41
+ models?: never;
33
42
  } | {
34
43
  ok: true;
35
44
  };
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { hasDisguisedText } from "./decision.js";
3
3
  import { authenticateApprover } from "./scope.js";
4
4
  import { isProviderId, validateSpec } from "./provider.js";
5
+ import { INSTALLATION_SCOPE } from "./agentconfig.js";
6
+ import { ALL_SLOTS, applyModelChoices, DEFAULT_PRESET, presetChange, previewModelChange, rowAt } from "./model-choices.js";
5
7
  export function previewSetup(store, repo, inputs) {
6
8
  if (!isProviderId(inputs.provider))
7
9
  return { ok: false, message: "Choose an installed provider." };
@@ -18,19 +20,36 @@ export function previewSetup(store, repo, inputs) {
18
20
  const seconds = Number(inputs.seconds);
19
21
  if (!/^[1-9][0-9]*$/.test(inputs.seconds) || !Number.isInteger(seconds) || seconds < 1 || seconds > 3600)
20
22
  return { ok: false, message: "Setup timeout must be 1–3600 seconds." };
21
- const fingerprint = createHash("sha256").update(JSON.stringify({ repo, inputs, beforeSetup: store.liveWorktreeSetup(repo), beforeConfig: store.phaseConfig(repo, "build") })).digest("hex");
22
- return { ok: true, fingerprint, command, seconds, provider: inputs.provider, model: inputs.model.trim() };
23
+ const models = installationModels(store);
24
+ const fingerprint = createHash("sha256").update(JSON.stringify({ repo, inputs, beforeSetup: store.liveWorktreeSetup(repo), beforeConfig: store.phaseConfig(repo, "build"),
25
+ ...(models === null ? {} : { models: [models.state, models.change] }) })).digest("hex");
26
+ return { ok: true, fingerprint, command, seconds, provider: inputs.provider, model: inputs.model.trim(), models };
23
27
  }
24
28
  export function approveSetup(store, repo, inputs, fingerprint, by, token, now) {
25
29
  return store.transact(() => {
26
- if (!authenticateApprover(store, by, token, repo).ok)
30
+ const proved = authenticateApprover(store, by, token, repo);
31
+ if (!proved.ok)
27
32
  return { ok: false, message: "Your operator credential is required." };
28
33
  const preview = previewSetup(store, repo, inputs);
29
34
  if (!preview.ok)
30
35
  return preview;
31
36
  if (preview.fingerprint !== fingerprint)
32
37
  return { ok: false, message: "Setup changed. Review it again." };
33
- store.setPhaseConfig(repo, "build", preview.provider, preview.model, by, now);
38
+ if (preview.models !== null) {
39
+ // The installation's models, through Settings → Models' one write path (an instance operator's act). The proof
40
+ // above ran in this same transaction, so the generation read here is the one it was checked against.
41
+ if (!store.isInstanceOperator(by))
42
+ return { ok: false, message: "Models for this installation aren't chosen yet. An instance operator sets them up first." };
43
+ const applied = applyModelChoices(store, { scope: INSTALLATION_SCOPE, change: presetChange(DEFAULT_PRESET), expected: { state: preview.models.state, change: preview.models.change }, strict: true, knownProject: () => false }, { actor: by, generation: store.accountOf(by)?.generation ?? -1, method: proved.method, live: () => true }, now);
44
+ if (!applied.ok)
45
+ return { ok: false, message: applied.problem };
46
+ }
47
+ // The project's own builder only when it differs from what it inherits: a project left on the installation's
48
+ // routine builder keeps following the installation's sizes and presets.
49
+ const inherited = store.phaseConfig(INSTALLATION_SCOPE, "build");
50
+ const same = inherited !== null && inherited.provider === preview.provider && inherited.model === preview.model;
51
+ if (!same || store.phaseConfig(repo, "build") !== null)
52
+ store.setPhaseConfig(repo, "build", preview.provider, preview.model, by, now);
34
53
  if (preview.command === "")
35
54
  store.clearWorktreeSetup(repo, by, now);
36
55
  else
@@ -38,3 +57,11 @@ export function approveSetup(store, repo, inputs, fingerprint, by, token, now) {
38
57
  return { ok: true };
39
58
  });
40
59
  }
60
+ // Last in the file, so the password tripwire's function positions (password-freshness.test.ts) stay as reviewed.
61
+ /** A new installation, nothing chosen yet: setup applies the default preset ("Sized by job") with the project's
62
+ * builder, previewed here row by row, so the first task files on exact models for every size. */
63
+ function installationModels(store) {
64
+ if (!ALL_SLOTS.every(slot => rowAt(store, INSTALLATION_SCOPE, slot) === null))
65
+ return null;
66
+ return previewModelChange(store, INSTALLATION_SCOPE, presetChange(DEFAULT_PRESET));
67
+ }
@@ -28,4 +28,10 @@ export declare function controlSetupHtml(data: {
28
28
  };
29
29
  }): Html;
30
30
  /** `fields` may still carry the session's csrf (a caller's habit): postForm writes the one CSRF field. */
31
- export declare function setupPreviewHtml(inputs: SetupInputs, fields: Record<string, string>): Html;
31
+ export declare function setupPreviewHtml(inputs: SetupInputs, fields: Record<string, string>, models?: {
32
+ label: string;
33
+ rows: {
34
+ role: string;
35
+ model: string;
36
+ }[];
37
+ } | null): Html;
@@ -27,7 +27,8 @@ export function controlSetupHtml(data) {
27
27
  html `<p>Give the agent the project's Toolroll handoff instructions.</p>${postForm("/control/instructions-preview", html `<button>Review instructions</button>`, { hidden: { repo: data.repo } })}`}</section><p><a href="/fleet">Configure planning, repair, and review agents</a> · <a href="/tasks/new">Describe a task</a></p>`;
28
28
  }
29
29
  /** `fields` may still carry the session's csrf (a caller's habit): postForm writes the one CSRF field. */
30
- export function setupPreviewHtml(inputs, fields) {
30
+ export function setupPreviewHtml(inputs, fields, models = null) {
31
31
  const { csrf: _csrf, ...rest } = fields;
32
- return html `<h1>Approve project setup</h1><div class="card"><p>${fields.repo ?? ""}</p><p><strong>Builder:</strong> ${inputs.provider} · ${inputs.model}</p><p><strong>Preparation:</strong></p><pre class="recap">${inputs.command || "No preparation command"}</pre><p>Preparation timeout: ${inputs.seconds} seconds.</p><p>The builder default applies to new tasks. Preparation applies to subsequent runs, including already approved tasks. Existing signed task settings and verification rules remain in force.</p>${postForm("/control/setup-approve", html `${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`)}<button>Approve project setup</button>`, { hidden: { ...rest, ...inputs } })}</div>`;
32
+ const chosen = models === null ? "" : html `<p><strong>Models for every project:</strong> ${models.label}</p><ul>${models.rows.map(row => html `<li>${row.role}: <span class="mono">${row.model}</span></li>`)}</ul><p class="meta">Change them later in Settings → Models.</p>`;
33
+ return html `<h1>Approve project setup</h1><div class="card"><p>${fields.repo ?? ""}</p><p><strong>Builder:</strong> ${inputs.provider} · ${inputs.model}</p>${chosen}<p><strong>Preparation:</strong></p><pre class="recap">${inputs.command || "No preparation command"}</pre><p>Preparation timeout: ${inputs.seconds} seconds.</p><p>The builder default applies to new tasks. Preparation applies to subsequent runs, including already approved tasks. Existing signed task settings and verification rules remain in force.</p>${postForm("/control/setup-approve", html `${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`)}<button>Approve project setup</button>`, { hidden: { ...rest, ...inputs } })}</div>`;
33
34
  }
@@ -97,7 +97,6 @@ export declare function sqliteLock(file: string, busyTimeoutMs?: number): Databa
97
97
  export declare function lockHeld(file: string): boolean;
98
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
99
99
  export declare function desktopUpdateStatus(stateDir: string): {
100
- version?: never;
101
100
  active: boolean;
102
101
  phase: string;
103
102
  detail: string;
@@ -111,6 +110,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
111
110
  wasRunning?: never;
112
111
  backupPath?: never;
113
112
  updateId?: never;
113
+ version?: never;
114
114
  buildId?: never;
115
115
  workDir?: never;
116
116
  } | {
@@ -6,6 +6,7 @@
6
6
  * fleet observations on top for people and agents reading the queue. A
7
7
  * diagnosis can go stale; it can never grant a lease.
8
8
  */
9
+ import { type Runner } from "./runner.js";
9
10
  import { type ChatSnapshot, type ReviewRequestOrigin, type ReviewRetryState, type Store, type TaskState } from "./store.js";
10
11
  export declare const DEFAULT_MAX_OPEN_DECISIONS = 5;
11
12
  export type TaskReadinessBlocker = {
@@ -95,6 +96,20 @@ export declare function dispatchRoleFor(store: Store, taskRef: number, now: Date
95
96
  /** The first requirement this task fails, in words, or null. */
96
97
  export declare function missingCapability(store: Store, taskRef: number, dispatchRepo: string | null, now: Date): string | null;
97
98
  /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
99
+ /** The builders that may take work in `repo`, as dispatch decides: registered and not retired (`all`), bound to the
100
+ * project (`eligible`; only the assigned one when a task names one), and checking in now (`alive`). The first-run
101
+ * "Builder connected" step reads the same answer, so it is done only when a task there could start. */
102
+ export declare function buildersFor(store: Store, repo: string, now: Date, assignedRunner?: string | null): {
103
+ all: Runner[];
104
+ eligible: Runner[];
105
+ alive: Runner[];
106
+ };
107
+ /** Whether a task in `repo` could start for want of nothing but a person: a live builder bound to it. */
108
+ export declare function builderReadyFor(store: Store, repo: string | null, now: Date): boolean;
109
+ /** The project the first-run "Builder connected" step speaks for, on Chat and the board alike: the open one; else the
110
+ * saved project of the first filed task the viewer may see (All projects); else, only before any task exists, the
111
+ * first managed project. Null when tasks exist but none sits in a project the viewer may see: then nothing says done. */
112
+ export declare function firstRunBuilderRepo(store: Store, selected: string | null, visible: (repo: string | null) => boolean, managed: readonly string[]): string | null;
98
113
  export declare function diagnoseTaskDispatch(store: Store, taskId: string, now: Date, readableRepos?: readonly string[] | null): DispatchDiagnosis | null;
99
114
  export declare function diagnosisIsDispatchable(diagnosis: DispatchDiagnosis | null): boolean;
100
115
  /** Attach the same lifecycle answer to the bounded fleet snapshot used by
package/dist/dispatch.js CHANGED
@@ -160,6 +160,31 @@ function approvedProfile(scope) {
160
160
  return scope.approvedProfile ?? scope.profile ?? null;
161
161
  }
162
162
  /** The read-side lifecycle answer. Project access limits dependency detail, never the readiness gate. */
163
+ /** The builders that may take work in `repo`, as dispatch decides: registered and not retired (`all`), bound to the
164
+ * project (`eligible`; only the assigned one when a task names one), and checking in now (`alive`). The first-run
165
+ * "Builder connected" step reads the same answer, so it is done only when a task there could start. */
166
+ export function buildersFor(store, repo, now, assignedRunner = null) {
167
+ const all = store.listRunners().filter(one => one.retiredAt === null);
168
+ const bound = all.filter(one => one.repos.includes(repo));
169
+ const eligible = assignedRunner === null ? bound : bound.filter(one => one.name === assignedRunner);
170
+ return { all, eligible, alive: eligible.filter(one => isAlive(one, now)) };
171
+ }
172
+ /** Whether a task in `repo` could start for want of nothing but a person: a live builder bound to it. */
173
+ export function builderReadyFor(store, repo, now) {
174
+ return repo !== null && buildersFor(store, repo, now).alive.length > 0;
175
+ }
176
+ /** The project the first-run "Builder connected" step speaks for, on Chat and the board alike: the open one; else the
177
+ * saved project of the first filed task the viewer may see (All projects); else, only before any task exists, the
178
+ * first managed project. Null when tasks exist but none sits in a project the viewer may see: then nothing says done. */
179
+ export function firstRunBuilderRepo(store, selected, visible, managed) {
180
+ if (selected !== null)
181
+ return selected;
182
+ const placed = store.handle.prepare("SELECT repo FROM task_ref WHERE repo IS NOT NULL ORDER BY id").all();
183
+ const first = placed.find(row => visible(row.repo));
184
+ if (first !== undefined)
185
+ return first.repo;
186
+ return store.hasAnyWork() ? null : managed[0] ?? null;
187
+ }
163
188
  export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
164
189
  const task = store.getTask(taskId);
165
190
  const ref = store.lookupRef(taskId);
@@ -271,9 +296,7 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
271
296
  const gap = missingCapability(store, ref.id, ref.repo, now);
272
297
  if (gap !== null)
273
298
  return answer("missing-requirement", "waiting", "Missing a requirement", gap, { action: "repair-capability", role });
274
- const all = store.listRunners().filter(one => one.retiredAt === null);
275
- const bound = all.filter(one => one.repos.includes(ref.repo));
276
- const eligible = ref.assignedRunner === null ? bound : bound.filter(one => one.name === ref.assignedRunner);
299
+ const { all, eligible, alive } = buildersFor(store, ref.repo, now, ref.assignedRunner);
277
300
  if (eligible.length === 0) {
278
301
  const detail = ref.assignedRunner === null
279
302
  ? all.length === 0
@@ -282,7 +305,6 @@ export function diagnoseTaskDispatch(store, taskId, now, readableRepos = null) {
282
305
  : `This task is assigned to ${ref.assignedRunner}, but that builder is not connected to this project.`;
283
306
  return answer("no-worker-registered", "waiting", "Builder not connected", detail, { action: "start-worker", role });
284
307
  }
285
- const alive = eligible.filter(one => isAlive(one, now));
286
308
  if (alive.length === 0) {
287
309
  return answer("no-worker-online", "waiting", "Builder disconnected", `${eligible.length === 1 ? "The builder for this project has" : "All builders for this project have"} stopped checking in. This task starts automatically when ${eligible.length === 1 ? "it reconnects" : "one reconnects"}.`, { action: "start-worker", role });
288
310
  }
@@ -25,7 +25,7 @@ export type FirstRunAction = {
25
25
  };
26
26
  /** `checking`: the answer is not in yet, so the step is shown neither done nor to do. */
27
27
  export type FirstRunStep = {
28
- key: "agent" | "project" | "task";
28
+ key: "agent" | "project" | "builder" | "task";
29
29
  title: string;
30
30
  done: boolean;
31
31
  action: FirstRunAction | null;
@@ -39,8 +39,11 @@ export type FirstRunFacts = {
39
39
  firstResultAt: string | null;
40
40
  signInCommand?: string;
41
41
  };
42
- /** The three steps, each done or with the one action that does it; null once the first result has arrived. */
42
+ /** The steps (the builder step is added by withBuilderStep), each done or with the one action that does it; null once the first result has arrived. */
43
43
  export declare function firstRunSteps(facts: FirstRunFacts): FirstRunStep[] | null;
44
+ /** The builder step, before the first task: under `toolroll serve` nothing connects a builder, and a task filed then
45
+ * waits forever, so the steps never all read done without one. It leads to Start a builder, which shows the command. */
46
+ export declare function withBuilderStep(steps: FirstRunStep[] | null, connected: boolean): FirstRunStep[] | null;
44
47
  export type OpenIssue = {
45
48
  number: number;
46
49
  title: string;
package/dist/first-run.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The first run: three plain steps to a first result, and three first tasks
2
+ * The first run: four plain steps to a first result, and three first tasks
3
3
  * to try. Everything here is derived from live facts on each render; the
4
4
  * list retires permanently with the first Ready result (the
5
5
  * 'first-success-at' installation fact). A suggestion only drafts words in
@@ -8,6 +8,7 @@
8
8
  import { run } from "./exec.js";
9
9
  import { hardenedGit } from "./teardown-git.js";
10
10
  import { githubIssues } from "./issues.js";
11
+ import { START_BUILDER_HREF } from "./needs-you.js";
11
12
  export const SIGN_IN_COMMAND = "claude auth login";
12
13
  export const SANDBOX_COMMAND = "npx toolroll demo";
13
14
  /** How a person starts on a real project (the demo hands off to this). */
@@ -27,7 +28,7 @@ export function signInCommandFor(states, platform) {
27
28
  return "codex login";
28
29
  return `npm install -g @anthropic-ai/claude-code${platform === "win32" ? ";" : " &&"} ${SIGN_IN_COMMAND}`;
29
30
  }
30
- /** The three steps, each done or with the one action that does it; null once the first result has arrived. */
31
+ /** The steps (the builder step is added by withBuilderStep), each done or with the one action that does it; null once the first result has arrived. */
31
32
  export function firstRunSteps(facts) {
32
33
  if (facts.firstResultAt !== null)
33
34
  return null;
@@ -39,6 +40,16 @@ export function firstRunSteps(facts) {
39
40
  { key: "task", title: "Your first task", done: facts.hasTask, action: facts.hasTask ? null : { kind: "link", label: "New task", href: "/tasks/new" } },
40
41
  ];
41
42
  }
43
+ /** The builder step, before the first task: under `toolroll serve` nothing connects a builder, and a task filed then
44
+ * waits forever, so the steps never all read done without one. It leads to Start a builder, which shows the command. */
45
+ export function withBuilderStep(steps, connected) {
46
+ if (steps === null)
47
+ return null;
48
+ const builder = { key: "builder", title: "Builder connected", done: connected,
49
+ action: connected ? null : { kind: "link", label: "Start a builder", href: START_BUILDER_HREF } };
50
+ const task = steps.findIndex(one => one.key === "task");
51
+ return task < 0 ? [...steps, builder] : [...steps.slice(0, task), builder, ...steps.slice(task)];
52
+ }
42
53
  export const GENERIC_FIRST_TASKS = [
43
54
  { source: "generic", label: "Add a test for an untested function", draft: "Find one function without a test and add a focused test for it." },
44
55
  { source: "generic", label: "Fix a lint warning", draft: "Fix one lint warning without changing behaviour." },
@@ -51,9 +51,12 @@ export type FolderLookRun = {
51
51
  };
52
52
  /** Starts one look. Production starts a child process; tests pass their own. */
53
53
  export type FolderLookRunner = (request: FolderLookRequest, deadlineMs: number) => FolderLookRun;
54
+ /** `started`: when the process that answered was started, in this console's order of starts. A caller that joined a
55
+ * look already under way gets that look's start, never its own, so an older answer always reads as older. */
54
56
  export type FolderLook<T> = {
55
57
  ok: true;
56
58
  value: T;
59
+ started?: number;
57
60
  } | {
58
61
  ok: false;
59
62
  why: "no-answer" | "failed";
@@ -189,6 +189,8 @@ export const childLook = (request, deadlineMs) => {
189
189
  /** Looks whose process hasn't ended, by request: callers asking the same thing share one; each holds a child slot
190
190
  * until its process is gone. */
191
191
  const inFlight = new Map();
192
+ /** Looks started so far: each look's place in that order is fixed when its process starts. */
193
+ let starts = 0;
192
194
  /** The same request however its paths were spelled: resolved paths, roots in order. */
193
195
  function lookKey(request) {
194
196
  const sorted = (paths) => paths.map(one => resolve(one)).sort();
@@ -215,13 +217,15 @@ export async function lookFolders(request, options = {}) {
215
217
  if (run === undefined) {
216
218
  if (inFlight.size >= FOLDER_LOOK_CHILDREN)
217
219
  return { ok: false, why: "no-answer" };
220
+ let started;
218
221
  try {
219
- run = (options.runner ?? childLook)(request, deadlineMs);
222
+ started = (options.runner ?? childLook)(request, deadlineMs);
220
223
  }
221
224
  catch {
222
225
  return { ok: false, why: "failed" };
223
226
  }
224
- const mine = run;
227
+ const mine = { ...started, started: ++starts };
228
+ run = mine;
225
229
  inFlight.set(key, mine);
226
230
  const forget = () => { if (inFlight.get(key) === mine)
227
231
  inFlight.delete(key); };
@@ -238,7 +242,7 @@ export async function lookFolders(request, options = {}) {
238
242
  const answer = first.value;
239
243
  if (answer === null || typeof answer !== "object" || answer.ok !== true)
240
244
  return { ok: false, why: "failed" };
241
- return { ok: true, value: answer.value };
245
+ return { ok: true, value: answer.value, started: run.started };
242
246
  }
243
247
  finally {
244
248
  clearTimeout(timer);
@@ -287,7 +291,7 @@ export async function lookIdentities(paths, options = {}) {
287
291
  if (row !== null && typeof row === "object" && text(row.path) && (row.real === null || text(row.real)))
288
292
  out.set(row.path, row.real);
289
293
  }
290
- return { ok: true, value: out };
294
+ return { ok: true, value: out, ...(looked.started === undefined ? {} : { started: looked.started }) };
291
295
  }
292
296
  /** A project's own `.mcp.json` (parsed object, or null when absent or not one), read off the thread. */
293
297
  export async function lookProjectMcp(repo, options = {}) {
package/dist/guides.js CHANGED
@@ -287,9 +287,10 @@ ${AUTHORITY_LINE}
287
287
 
288
288
  - One command, inside the repository: \`npx toolroll up\` (or
289
289
  \`bunx toolroll up\`; Node 22.13+ must be installed — Bun's own
290
- runtime cannot host it). \`up\` mints the first login and prints it (saved
291
- beside the database as \`up-login.txt\`), registers the machine as a
292
- worker, runs the console, opens the browser. Later starts ask for the
290
+ runtime cannot host it). \`up\` mints the first login and saves it beside the
291
+ database as \`up-login.txt\` (every start prints that path and the
292
+ \`cat\` command that shows the password, never the password itself),
293
+ registers the machine as a worker, runs the console, opens the browser. Later starts ask for the
293
294
  password at most once and remember it on that machine.
294
295
  - From a phone over a tailnet: add \`--host 0.0.0.0 --allow-host
295
296
  <name>:4180\`.
@@ -47,6 +47,11 @@ export declare const RUNTIME_TOOLS: Record<RuntimeTool, {
47
47
  pkg: string;
48
48
  }>;
49
49
  export declare const WATCH_EVERY_MS: number;
50
+ /** The current Claude models by exact id, offered even before the first catalog check has run. */
51
+ export declare const CLAUDE_EXACT: readonly {
52
+ value: string;
53
+ label: string;
54
+ }[];
50
55
  /** `anthropic/claude-opus-5.5` → `claude-opus-5-5`, the name the Claude CLI and API use. */
51
56
  export declare function claudeIdOf(openrouterId: string): string | null;
52
57
  /** The Codex CLI's own list — account-specific, maintained by the CLI. */
@@ -58,6 +58,12 @@ export const WATCH_EVERY_MS = 6 * 3_600_000;
58
58
  const NEW_FOR_MS = 14 * 86_400_000;
59
59
  const FAMILIES = ["opus", "sonnet", "haiku", "fable"];
60
60
  const ALIAS_WORDS = { opus: "complex tasks", sonnet: "everyday coding", haiku: "smaller tasks" };
61
+ /** The current Claude models by exact id, offered even before the first catalog check has run. */
62
+ export const CLAUDE_EXACT = Object.freeze([
63
+ { value: "claude-opus-5-5", label: "Claude Opus 5.5" },
64
+ { value: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
65
+ { value: "claude-haiku-5-5", label: "Claude Haiku 5.5" },
66
+ ]);
61
67
  const title = (word) => word.charAt(0).toUpperCase() + word.slice(1);
62
68
  const stripVendor = (name) => name.replace(/^(Anthropic|OpenAI|Google): /, "");
63
69
  /** `anthropic/claude-opus-5.5` → `claude-opus-5-5`, the name the Claude CLI and API use. */
@@ -182,12 +188,15 @@ export function modelWords(store, provider, model) {
182
188
  export function modelOptions(store, provider, now, home = homedir()) {
183
189
  const option = (model) => ({ value: model.id, label: `${model.name} · ${priceWords(model)}${isNewModel(model, now) ? " · New" : ""}`, isNew: isNewModel(model, now) });
184
190
  if (provider === "claude") {
191
+ // Exact ids first (Settings → Models presets write only these); the short names float to the newest version.
192
+ const seen = seenModels(store, "claude").slice(0, 20);
193
+ const builtIn = CLAUDE_EXACT.filter(one => !seen.some(model => model.id === one.value)).map(one => ({ ...one, isNew: false }));
185
194
  const aliases = ["sonnet", "opus", "haiku"].map(alias => {
186
195
  const target = aliasTarget(store, alias);
187
196
  // Short enough that a phone's select still shows which model it means.
188
- return { value: alias, label: target === null ? `${title(alias)} — ${ALIAS_WORDS[alias]}` : `${title(alias)} · latest (${target.name.replace(/^Claude /, "")})`, isNew: false };
197
+ return { value: alias, label: target === null ? `${title(alias)} (floating) — ${ALIAS_WORDS[alias]}` : `${title(alias)} (floating) · now ${target.name.replace(/^Claude /, "")}`, isNew: false };
189
198
  });
190
- return [...aliases, ...seenModels(store, "claude").slice(0, 20).map(option)];
199
+ return [...seen.map(option), ...builtIn, ...aliases];
191
200
  }
192
201
  if (provider === "codex") {
193
202
  const live = codexCatalog(home);