toolroll 0.9.82 → 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 (92) hide show
  1. package/CHANGELOG.md +41 -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/dispatch.d.ts +15 -0
  15. package/dist/dispatch.js +26 -4
  16. package/dist/first-run.d.ts +5 -2
  17. package/dist/first-run.js +13 -2
  18. package/dist/first-tasks-child.d.ts +1 -0
  19. package/dist/first-tasks-child.js +11 -0
  20. package/dist/folder-look.d.ts +110 -0
  21. package/dist/folder-look.js +306 -0
  22. package/dist/guides.js +4 -3
  23. package/dist/learning-child.d.ts +1 -0
  24. package/dist/learning-child.js +34 -0
  25. package/dist/learning-look.d.ts +25 -0
  26. package/dist/learning-look.js +72 -0
  27. package/dist/model-catalog.d.ts +5 -0
  28. package/dist/model-catalog.js +11 -2
  29. package/dist/model-choices.d.ts +188 -0
  30. package/dist/model-choices.js +405 -0
  31. package/dist/model-names.d.ts +6 -0
  32. package/dist/model-names.js +34 -0
  33. package/dist/models-ui.d.ts +69 -5
  34. package/dist/models-ui.js +53 -10
  35. package/dist/needs-you.d.ts +8 -0
  36. package/dist/needs-you.js +8 -0
  37. package/dist/operate.d.ts +53 -0
  38. package/dist/operate.js +277 -49
  39. package/dist/project-admission.d.ts +4 -1
  40. package/dist/project-admission.js +23 -2
  41. package/dist/project-identity.d.ts +44 -0
  42. package/dist/project-identity.js +153 -0
  43. package/dist/project-knowledge.d.ts +6 -6
  44. package/dist/project-tools.d.ts +4 -1
  45. package/dist/project-tools.js +7 -2
  46. package/dist/project.d.ts +4 -1
  47. package/dist/project.js +11 -4
  48. package/dist/repos.d.ts +7 -0
  49. package/dist/repos.js +57 -0
  50. package/dist/serve.js +91 -17
  51. package/dist/server/chat.js +5 -4
  52. package/dist/server/chrome.d.ts +3 -0
  53. package/dist/server/chrome.js +5 -2
  54. package/dist/server/console.css +5 -2
  55. package/dist/server/flows.js +13 -3
  56. package/dist/server/guards.d.ts +4 -0
  57. package/dist/server/guards.js +12 -3
  58. package/dist/server/http.d.ts +12 -0
  59. package/dist/server/http.js +9 -0
  60. package/dist/server/pages.js +102 -92
  61. package/dist/server/people-tokens.js +40 -20
  62. package/dist/server/render-pages.d.ts +12 -16
  63. package/dist/server/render-pages.js +30 -69
  64. package/dist/server/render-people.d.ts +14 -2
  65. package/dist/server/render-people.js +35 -12
  66. package/dist/server/render-settings.js +1 -0
  67. package/dist/server/render-tasks.js +7 -14
  68. package/dist/server/request-context.d.ts +2 -0
  69. package/dist/server/route-table.js +8 -1
  70. package/dist/server/runtime.d.ts +4 -0
  71. package/dist/server/runtime.js +1 -0
  72. package/dist/server/settings.d.ts +58 -1
  73. package/dist/server/settings.js +211 -71
  74. package/dist/server/signup-gate.d.ts +9 -0
  75. package/dist/server/signup-gate.js +40 -0
  76. package/dist/server/tasks.js +19 -5
  77. package/dist/settings-kit-script.d.ts +21 -0
  78. package/dist/settings-kit-script.js +67 -0
  79. package/dist/settings-kit.d.ts +137 -0
  80. package/dist/settings-kit.js +186 -0
  81. package/dist/settings-proofs.d.ts +64 -0
  82. package/dist/settings-proofs.js +101 -0
  83. package/dist/setup-guide.js +4 -2
  84. package/dist/spend-report.d.ts +87 -0
  85. package/dist/spend-report.js +265 -0
  86. package/dist/spend-ui.d.ts +29 -8
  87. package/dist/spend-ui.js +74 -39
  88. package/dist/task-sizing.d.ts +5 -3
  89. package/dist/task-sizing.js +4 -4
  90. package/dist/up-processes.d.ts +13 -0
  91. package/dist/up-processes.js +6 -2
  92. 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
  }
@@ -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." },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Started only by learning-look.ts: one project's first-task suggestions (first-run.ts findFirstTasks), in a process
3
+ * of its own so the hardened git's synchronous reads of the project's folder never hold the console's thread. Its one
4
+ * argument is JSON naming the project; it prints the suggestions as JSON and ends.
5
+ */
6
+ import { findFirstTasks } from "./first-run.js";
7
+ const input = JSON.parse(process.argv[2] ?? "null");
8
+ if (input === null || typeof input.repo !== "string" || input.repo === "" || input.repo.includes("\0"))
9
+ process.exit(2);
10
+ const found = await findFirstTasks(input.repo);
11
+ process.stdout.write(JSON.stringify(found), () => process.exit(0));
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Reads of a person's own folders, kept off the console's thread.
3
+ *
4
+ * A folder can stop answering: a drive that disconnected without unmounting, a folder macOS keeps in iCloud (Desktop &
5
+ * Documents), or one behind a privacy prompt nobody has answered. Opening it then blocks until the system answers, and
6
+ * a synchronous read on the console's thread freezes every page with it (the 0.9.82 deploy, 2026-10-31: the console
7
+ * listened for twenty minutes without answering, its main thread inside a directory read). So every such read runs in a
8
+ * child process, the same read it always was, and the console waits for it only as long as a deadline allows: a folder
9
+ * that didn't answer degrades the one thing that needed it, in plain words, and the next look tries again.
10
+ *
11
+ * A look never goes into another mounted drive, /Volumes, or the protected folders under ~/Library, unless that is the
12
+ * folder the person chose. Its answers are filesystem data, read exactly as before (directory names, a repository's
13
+ * `.git/config` as a plain bounded file never followed through a link), and the caller still checks every path against
14
+ * its own ceiling.
15
+ */
16
+ /** How long the console waits for a look before saying the folder didn't answer. */
17
+ export declare const FOLDER_LOOK_MS = 4000;
18
+ /** The `url` of `[remote "origin"]` in a git config's text. Plain JavaScript with no outside names: the look's child
19
+ * runs this same function (its source is sent along), so the parent and the child read a config one way. */
20
+ export declare function originUrlIn(config: string): string | null;
21
+ export type FolderLookRequest =
22
+ /** One folder's sub-folders, as the browse page shows them: at most 400 kept of 2,000 entries read, links resolved
23
+ * and kept only inside a root. */
24
+ {
25
+ op: "list";
26
+ path: string;
27
+ roots: readonly string[];
28
+ home: string;
29
+ }
30
+ /** The origin of each registered project, and of each repository directly in a root (at most 400 folders a root). */
31
+ | {
32
+ op: "origins";
33
+ registered: readonly string[];
34
+ roots: readonly string[];
35
+ home: string;
36
+ }
37
+ /** Where each path really is (its real path, when that is a folder): a project's identity, resolved off the thread. */
38
+ | {
39
+ op: "identities";
40
+ paths: readonly string[];
41
+ }
42
+ /** A project's own `.mcp.json`, read as a plain bounded file and parsed (null when absent or not a JSON object). */
43
+ | {
44
+ op: "projectMcp";
45
+ path: string;
46
+ };
47
+ /** One look under way: its answer, and when its process has ended (a stuck child can outlive its answer). */
48
+ export type FolderLookRun = {
49
+ answer: Promise<unknown>;
50
+ ended: Promise<void>;
51
+ };
52
+ /** Starts one look. Production starts a child process; tests pass their own. */
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. */
56
+ export type FolderLook<T> = {
57
+ ok: true;
58
+ value: T;
59
+ started?: number;
60
+ } | {
61
+ ok: false;
62
+ why: "no-answer" | "failed";
63
+ };
64
+ /** At most this many look processes at once, stuck ones included: a folder that never answers costs one each. */
65
+ export declare const FOLDER_LOOK_CHILDREN = 8;
66
+ /** The production look: the script above in a child process of this same Node (or Electron as Node), killed at its
67
+ * deadline. Its environment carries nothing of this process's (no NODE_OPTIONS, no secrets). At the deadline its pipe
68
+ * is closed and nothing of it keeps this process alive; `ended` settles only when the process has really gone. */
69
+ export declare const childLook: FolderLookRunner;
70
+ /** How many look processes are running now (stuck ones included). */
71
+ export declare function folderLooksRunning(): number;
72
+ /**
73
+ * One look, answered within `deadlineMs` whatever the folder does: its value, or that the folder didn't answer (it ran
74
+ * past the deadline, the same look is still stuck, or every child slot is held) or the look failed. A look already
75
+ * under way for the same request is joined, never started twice.
76
+ */
77
+ export declare function lookFolders(request: FolderLookRequest, options?: {
78
+ runner?: FolderLookRunner;
79
+ deadlineMs?: number;
80
+ }): Promise<FolderLook<unknown>>;
81
+ export type FolderEntry = {
82
+ name: string;
83
+ path: string;
84
+ git: boolean;
85
+ };
86
+ export type FolderLookOptions = {
87
+ runner?: FolderLookRunner;
88
+ deadlineMs?: number;
89
+ home?: string;
90
+ };
91
+ /** A folder's sub-folders for the browse page (null `at`: outside every root, or not a folder). */
92
+ export declare function listFolder(path: string, roots: readonly string[], options?: FolderLookOptions): Promise<FolderLook<{
93
+ at: string | null;
94
+ entries: FolderEntry[];
95
+ }>>;
96
+ /** The origin URL of each registered project, and the repositories directly in each root with theirs. */
97
+ export declare function localOrigins(registered: readonly string[], roots: readonly string[], options?: FolderLookOptions): Promise<FolderLook<{
98
+ registered: {
99
+ path: string;
100
+ url: string | null;
101
+ }[];
102
+ found: {
103
+ path: string;
104
+ url: string | null;
105
+ }[];
106
+ }>>;
107
+ /** Each path's identity (its real path when that is a folder, else null), resolved off the thread. */
108
+ export declare function lookIdentities(paths: readonly string[], options?: FolderLookOptions): Promise<FolderLook<Map<string, string | null>>>;
109
+ /** A project's own `.mcp.json` (parsed object, or null when absent or not one), read off the thread. */
110
+ export declare function lookProjectMcp(repo: string, options?: FolderLookOptions): Promise<FolderLook<Record<string, unknown> | null>>;