toolroll 0.9.88 → 0.9.90

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 (154) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +16 -10
  3. package/THIRD_PARTY_NOTICES.md +37 -0
  4. package/dist/agent-board.d.ts +9 -0
  5. package/dist/agent-board.js +20 -0
  6. package/dist/agent-fence.js +20 -2
  7. package/dist/approval-authority.d.ts +7 -0
  8. package/dist/approval-authority.js +13 -2
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +692 -28
  10. package/dist/browser/terminal.js +42 -42
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +30 -30
  13. package/dist/browser-workspace.d.ts +13 -0
  14. package/dist/builder.js +5 -2
  15. package/dist/chat-actions.d.ts +5 -0
  16. package/dist/chat-actions.js +7 -1
  17. package/dist/chat-channel.js +11 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-task-actions.d.ts +1 -0
  20. package/dist/check-access.d.ts +3 -0
  21. package/dist/check-access.js +4 -1
  22. package/dist/cli-help.d.ts +1 -1
  23. package/dist/cli-help.js +6 -2
  24. package/dist/cli.d.ts +6 -0
  25. package/dist/cli.js +23 -0
  26. package/dist/coding-ui.js +4 -2
  27. package/dist/connect-cli.js +2 -1
  28. package/dist/console-instance.d.ts +34 -1
  29. package/dist/console-instance.js +124 -15
  30. package/dist/contracts/cli.d.ts +72 -0
  31. package/dist/contracts/cli.js +13 -0
  32. package/dist/contracts/console-api.d.ts +9 -2
  33. package/dist/contracts/console-api.js +9 -2
  34. package/dist/desktop-host.js +8 -3
  35. package/dist/desktop-update.js +2 -1
  36. package/dist/dispatch.d.ts +3 -0
  37. package/dist/dispatch.js +22 -2
  38. package/dist/easy-start-args.d.ts +13 -0
  39. package/dist/easy-start-args.js +24 -0
  40. package/dist/easy-start.d.ts +77 -0
  41. package/dist/easy-start.js +321 -0
  42. package/dist/egress.js +5 -0
  43. package/dist/first-run.d.ts +77 -0
  44. package/dist/first-run.js +45 -0
  45. package/dist/flow-starters.js +3 -0
  46. package/dist/flow-triggers.js +4 -0
  47. package/dist/folder-gate.d.ts +32 -0
  48. package/dist/folder-gate.js +43 -0
  49. package/dist/guides.js +1 -1
  50. package/dist/invoke.js +11 -6
  51. package/dist/laptop-background.d.ts +171 -0
  52. package/dist/laptop-background.js +550 -0
  53. package/dist/laptop-service.d.ts +111 -0
  54. package/dist/laptop-service.js +268 -0
  55. package/dist/lead-cli.js +2 -1
  56. package/dist/lead-doors.d.ts +6 -0
  57. package/dist/lead-doors.js +20 -2
  58. package/dist/live-records.d.ts +1 -1
  59. package/dist/live-records.js +3 -12
  60. package/dist/local-owner.d.ts +124 -0
  61. package/dist/local-owner.js +212 -0
  62. package/dist/login-secrets.d.ts +60 -0
  63. package/dist/login-secrets.js +195 -0
  64. package/dist/mcp-oauth.d.ts +4 -0
  65. package/dist/mcp-oauth.js +13 -7
  66. package/dist/model-choices.d.ts +43 -0
  67. package/dist/model-choices.js +80 -0
  68. package/dist/operate.d.ts +46 -1
  69. package/dist/operate.js +461 -40
  70. package/dist/person-proof.js +3 -0
  71. package/dist/project-access.js +15 -1
  72. package/dist/project-admission.d.ts +6 -1
  73. package/dist/project-admission.js +44 -7
  74. package/dist/project-identity.d.ts +8 -0
  75. package/dist/project-identity.js +43 -0
  76. package/dist/project-knowledge.d.ts +7 -7
  77. package/dist/project.d.ts +1 -0
  78. package/dist/project.js +6 -0
  79. package/dist/proposal.js +11 -1
  80. package/dist/quick-start.d.ts +3 -1
  81. package/dist/quick-start.js +6 -1
  82. package/dist/recipes.js +12 -1
  83. package/dist/release-cli.js +2 -1
  84. package/dist/runner-process.d.ts +9 -0
  85. package/dist/runner-process.js +18 -0
  86. package/dist/sample-project.d.ts +31 -0
  87. package/dist/sample-project.js +141 -0
  88. package/dist/scope.d.ts +24 -2
  89. package/dist/scope.js +74 -3
  90. package/dist/serve.js +157 -28
  91. package/dist/server/chat.js +10 -3
  92. package/dist/server/chrome.d.ts +10 -2
  93. package/dist/server/chrome.js +7 -5
  94. package/dist/server/console.css +10 -0
  95. package/dist/server/guards.d.ts +3 -0
  96. package/dist/server/guards.js +22 -2
  97. package/dist/server/http.d.ts +22 -0
  98. package/dist/server/http.js +58 -0
  99. package/dist/server/live.js +4 -14
  100. package/dist/server/mutation-key.d.ts +70 -0
  101. package/dist/server/mutation-key.js +142 -0
  102. package/dist/server/pages.js +12 -9
  103. package/dist/server/people-tokens.js +240 -20
  104. package/dist/server/remote.d.ts +2 -0
  105. package/dist/server/remote.js +19 -4
  106. package/dist/server/render-new-task.js +5 -1
  107. package/dist/server/render-pages.d.ts +2 -1
  108. package/dist/server/render-pages.js +1 -1
  109. package/dist/server/render-people.d.ts +36 -0
  110. package/dist/server/render-people.js +65 -1
  111. package/dist/server/render-projects.js +4 -2
  112. package/dist/server/render-tasks.d.ts +12 -0
  113. package/dist/server/render-tasks.js +16 -6
  114. package/dist/server/request-context.d.ts +6 -0
  115. package/dist/server/route-table.d.ts +16 -0
  116. package/dist/server/route-table.js +121 -0
  117. package/dist/server/runtime.d.ts +6 -4
  118. package/dist/server/session.d.ts +31 -1
  119. package/dist/server/session.js +12 -2
  120. package/dist/server/settings.js +63 -8
  121. package/dist/server/task-views.js +10 -1
  122. package/dist/server/tasks.d.ts +10 -0
  123. package/dist/server/tasks.js +59 -9
  124. package/dist/server/terminals.js +15 -2
  125. package/dist/server/welcome.d.ts +22 -16
  126. package/dist/server/welcome.js +150 -52
  127. package/dist/settings-groups-ui.d.ts +7 -1
  128. package/dist/settings-groups-ui.js +23 -2
  129. package/dist/settings-proofs.js +1 -0
  130. package/dist/setup-guide.d.ts +2 -2
  131. package/dist/setup-guide.js +6 -0
  132. package/dist/store.d.ts +124 -1
  133. package/dist/store.js +245 -12
  134. package/dist/surface.d.ts +10 -0
  135. package/dist/surface.js +40 -1
  136. package/dist/task-text.d.ts +6 -0
  137. package/dist/task-text.js +17 -0
  138. package/dist/teardown-git.js +16 -1
  139. package/dist/telegram-lead.js +2 -0
  140. package/dist/terminal-text.d.ts +11 -0
  141. package/dist/terminal-text.js +26 -0
  142. package/dist/toolroll-update.d.ts +8 -1
  143. package/dist/toolroll-update.js +151 -4
  144. package/dist/tui-contract.d.ts +118 -0
  145. package/dist/tui-contract.js +9 -0
  146. package/dist/tui-source.d.ts +14 -0
  147. package/dist/tui-source.js +182 -0
  148. package/dist/tui.js +129 -0
  149. package/dist/up-processes.d.ts +13 -1
  150. package/dist/up-processes.js +13 -3
  151. package/dist/welcome-facts.d.ts +18 -0
  152. package/dist/welcome-facts.js +79 -0
  153. package/dist/youiyoux-intake.js +11 -1
  154. package/package.json +4 -2
@@ -1,3 +1,4 @@
1
+ import { titleWithin } from "../task-text.js";
1
2
  import { parseFeedbackImage, sameFeedbackImage } from "../result-review.js";
2
3
  import { archiveTaskFamily, dropFeedbackImage, releaseAuthorityOfTask, restoreTaskFamily, revokeRelease, runLiveCommands } from "../store.js";
3
4
  import { applyTidy, approverStandsNow, familyRepoOf, releaseFactsOf, tidyDays, tidyDigest, tidyPreview, TIDY_DEFAULT_DAYS } from "../tidy.js";
@@ -58,14 +59,14 @@ import { structuredHandoffView, terminalDiffView } from "../result-evidence-read
58
59
  import { fileAddTestsTask, requestFollowUpChecks } from '../result-follow-ups.js';
59
60
  import { commentSourceKey, isRevisionFeedback, parseResultTab, resultReturnTarget } from "../result-review.js";
60
61
  import { isAlive as runnerAlive } from "../runner.js";
61
- import { passwordlessSessionFor, acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, freshnessFor, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
62
+ import { passwordlessSessionFor, acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, freshnessFor, localApprovalEligible, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
62
63
  import { subagentNames as subagentNamesOf } from "../spend.js";
63
64
  import { verifiedAuthor } from "../store.js";
64
65
  import { runCostWords } from "../summary.js";
65
66
  import { requestTaskStop, resumeTaskStop } from "../task-control.js";
66
67
  import { templateByName } from "../templates.js";
67
68
  import { TEXT_LIMITS } from "../text-limits.js";
68
- import { agentQuestion, answerAgentQuestion, markAgentSeen } from "../agent-board.js";
69
+ import { AGENT_VIEW_ID, agentQuestion, answerAgentQuestion, holdOwnAgentView, markAgentSeen, releaseOwnAgentView } from "../agent-board.js";
69
70
  import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
70
71
  import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
71
72
  import { requestContext } from "./request-context.js";
@@ -573,10 +574,8 @@ export function createTasksHandlers(runtime) {
573
574
  return sendScreen(response, 409, screen("Result changed", html `<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>`, { chrome: chromeFor(project, "runs") }));
574
575
  }
575
576
  const selected = picked !== null ? picked.selected : selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
576
- // Opening a run's own result page is viewing it: it leaves this person's unread Done on the Agents board — but only
577
- // once the run has finished (cockpit §3): a page shown while it was still running saw no finished version.
578
- if (wantedId !== null && selected?.run != null && store.getRun(selected.run.id)?.finishedAt != null)
579
- markAgentSeen(store, who.name, selected.run.id, now);
577
+ // Opening a run's own result page is viewing it: the shell says so with a keyed POST /agents/seen (easy-start N1:
578
+ // a page load never writes), which leaves this person's unread Done once the run has finished.
580
579
  const reviewPage = reviewCockpitPage(chromeFor(wantedId === null ? project : chosenProject, "runs"), {
581
580
  queue: ranked,
582
581
  queueCap: REVIEW_QUEUE_CAP,
@@ -1004,7 +1003,11 @@ export function createTasksHandlers(runtime) {
1004
1003
  }
1005
1004
  }
1006
1005
  const id = (body.get("id") ?? "").trim();
1007
- const title = body.get("title") ?? "";
1006
+ // The New task box stops at a title's length (render-new-task.ts): the title is all the planner reads of a no-goal
1007
+ // filing. A direct POST longer than that is cut at a whole word, with an ellipsis (task-text.ts titleWithin), as a
1008
+ // belt; a refusal keeps the person's own words (the draft below).
1009
+ const typed = body.get("title") ?? "";
1010
+ const title = titleWithin(typed);
1008
1011
  // The EFFECTIVE placement (repo onboarding, findings 15/35): the
1009
1012
  // trimmed, nonempty posted repo, else the open project — an empty
1010
1013
  // input falls through correctly. In root mode the effective path is
@@ -1021,7 +1024,7 @@ export function createTasksHandlers(runtime) {
1021
1024
  // direct POST with no project open must not mint an unplaced task
1022
1025
  // under a ceiling. Unscoped mode keeps its historic unplaced filings.
1023
1026
  // A refusal from here on is the add page again with the person's own words (the old task list kept them too).
1024
- const draft = { title, goal: body.get("goal") ?? "", not: body.get("not") ?? "", touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent };
1027
+ const draft = { title: typed, goal: body.get("goal") ?? "", not: body.get("not") ?? "", touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent };
1025
1028
  if (!unscopedMode && effective === "") {
1026
1029
  return newTaskScreen(ctx, 400, "name a repository — no project is open, so the task must say where it belongs", draft);
1027
1030
  }
@@ -2473,7 +2476,9 @@ export function createTasksHandlers(runtime) {
2473
2476
  }
2474
2477
  // An empty password stands for an explicit identity-provider re-check not yet spent, or (cockpit B2) a password
2475
2478
  // remembered on this session; approve() decides whether these terms may stand on a remembered one.
2476
- if (token === "" && !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null) {
2479
+ // Easy-start C5: a laptop's local owner approves eligible work with no password (approve() proves it again).
2480
+ if (token === "" && !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null
2481
+ && !(who.authority === "local-owner" && localApprovalEligible(store, who.name, taskId).ok)) {
2477
2482
  return approvalProblem("approval requires your password, typed again", 400);
2478
2483
  }
2479
2484
  const scopeRow = store.getScope(taskId);
@@ -3087,6 +3092,7 @@ export function createTasksHandlers(runtime) {
3087
3092
  }
3088
3093
  const registrations = handlersOf("tasks", {
3089
3094
  ...tidyHandlers,
3095
+ ...createAgentSignalHandlers(runtime),
3090
3096
  "code.page": showCode("workspace"),
3091
3097
  "code.session": showCode("session"),
3092
3098
  "code.state": showCode("state"),
@@ -3261,3 +3267,47 @@ export function retiredTasksAddress(route, url) {
3261
3267
  }
3262
3268
  /** How many cards a Board column shows; the column's count covers them all and its "View all" opens the list. */
3263
3269
  export const BOARD_COLUMN_LIMIT = 40;
3270
+ /**
3271
+ * Easy-start C1 (N1): the two writes a page load used to make, as keyed POSTs the shell sends (wave 1c wires the
3272
+ * browser). `agents.seen`: a finished result this person opened leaves their unread Done. `agents.watch`: hold (on each
3273
+ * keep-alive) or release (on unload) the short lease that quiets chat pings while this person's page shows the agent.
3274
+ * Each re-proves the run is in the person's projects; a reply carries nothing else.
3275
+ */
3276
+ export function createAgentSignalHandlers(runtime) {
3277
+ const { store, runVisible } = runtime;
3278
+ const answer = (response, status, ok) => respond(response, status, "application/json", JSON.stringify({ ok }));
3279
+ const visibleRun = (who, raw) => {
3280
+ const id = raw !== null && /^[1-9][0-9]{0,14}$/.test(raw) ? Number(raw) : null;
3281
+ const run = id === null ? null : store.getRun(id);
3282
+ const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
3283
+ return run === null || !runVisible(run) || !store.accountCanAccess(who.name, repo) ? null : run;
3284
+ };
3285
+ async function agentSeen(ctx) {
3286
+ const { who, response, now, posted } = ctx;
3287
+ const run = visibleRun(who, readForm(posted, CONSOLE_FORMS.agentSeen).get("run"));
3288
+ if (run === null)
3289
+ return answer(response, 404, false);
3290
+ // Only once it finished: a page shown while it ran saw no finished version (cockpit §3).
3291
+ if (run.finishedAt === null)
3292
+ return answer(response, 409, false);
3293
+ markAgentSeen(store, who.name, run.id, now);
3294
+ return answer(response, 200, true);
3295
+ }
3296
+ async function agentWatch(ctx) {
3297
+ const { who, response, now, posted } = ctx;
3298
+ const body = readForm(posted, CONSOLE_FORMS.agentWatch);
3299
+ const view = body.get("view") ?? "", op = body.get("op");
3300
+ if (!AGENT_VIEW_ID.test(view) || (op !== "hold" && op !== "release"))
3301
+ return answer(response, 400, false);
3302
+ if (op === "release") {
3303
+ releaseOwnAgentView(store, view, who.name, now);
3304
+ return answer(response, 200, true);
3305
+ }
3306
+ const run = visibleRun(who, body.get("run"));
3307
+ if (run === null)
3308
+ return answer(response, 404, false);
3309
+ const held = holdOwnAgentView(store, view, who.name, run.id, now);
3310
+ return answer(response, held ? 200 : 409, held);
3311
+ }
3312
+ return { "agents.seen": agentSeen, "agents.watch": agentWatch };
3313
+ }
@@ -17,6 +17,7 @@
17
17
  import { randomBytes } from "node:crypto";
18
18
  import { STATUS_CODES } from "node:http";
19
19
  import { dirname, join } from "node:path";
20
+ import { KEY_PROTOCOL } from "./mutation-key.js";
20
21
  import { WebSocketServer } from "ws";
21
22
  import { readForm, CONSOLE_FORMS } from "../contracts/console-api.js";
22
23
  import { html, htmlString } from "../html.js";
@@ -55,9 +56,14 @@ export function createTerminalHandlers(runtime, deps) {
55
56
  const transportOfRequest = (request) => transportOf({ peer: request.socket.remoteAddress, joinSource: deps.forwardedSourceOf(request),
56
57
  forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
57
58
  const sockets = new Set();
58
- const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES, perMessageDeflate: false });
59
+ // Easy-start C2: a page offers `toolroll` and, on a protected loopback install, `toolroll.key.<key>`; only `toolroll` is
60
+ // ever selected, so the key is never echoed.
61
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES, perMessageDeflate: false,
62
+ handleProtocols: protocols => protocols.has(KEY_PROTOCOL) ? KEY_PROTOCOL : false });
59
63
  /** An operator signed in with a browser, of the generation they signed in with; else null. */
60
- const operatorOf = (who) => who?.via === "cookie" && who.role === "approver" && !store.isDemo() && codingActorAllowed({ name: who.name, generation: who.session.generation }) ? who : null;
64
+ const operatorOf = (who) =>
65
+ // Easy-start C1: never a local owner's session (a laptop's passwordless owner), for a new terminal or an existing one.
66
+ who?.via === "cookie" && who.authority === "full" && who.role === "approver" && !store.isDemo() && codingActorAllowed({ name: who.name, generation: who.session.generation }) ? who : null;
61
67
  /** The project a terminal works in is one the coding workspace admits for this person, now. */
62
68
  const projectAllowed = (name, repo) => rowVisible(liveCeiling(), repo) && store.accountCanAccess(name, repo) && codingProjectAllowed(repo);
63
69
  const owned = (who, terminal) => terminal !== null && terminal.owner === who.name && terminal.generation === who.session.generation;
@@ -232,6 +238,13 @@ export function createTerminalHandlers(runtime, deps) {
232
238
  refuseUpgrade(socket, 401, "Sign in as an installation-wide operator.");
233
239
  return true;
234
240
  }
241
+ // Easy-start C2: typing into a terminal is a mutation: on a protected loopback install the socket offers the session's
242
+ // key (its protocol list), which a cookie holder on another port can't read.
243
+ const keyed = runtime.browserKeys.check(who, request, null);
244
+ if (keyed !== null) {
245
+ refuseUpgrade(socket, keyed.status, keyed.message);
246
+ return true;
247
+ }
235
248
  const terminal = deps.host?.get(id) ?? null;
236
249
  if (!owned(who, terminal) || !projectAllowed(who.name, terminal.repo)) {
237
250
  refuseUpgrade(socket, 404, "No such terminal.");
@@ -1,22 +1,28 @@
1
- import { type FirstRunStep } from "../first-run.js";
2
- import { type CheckStep } from "./kit.js";
3
- import type { ServerRuntime } from "./runtime.js";
1
+ import { type WelcomeModel } from "../first-run.js";
2
+ import { type Html } from "../html.js";
4
3
  import { type Chrome, type Screen } from "./chrome.js";
5
- export type WelcomeFacts = {
6
- steps: readonly FirstRunStep[];
7
- projects: number;
8
- /** This person has a live push enrollment on some device. */
9
- alerts: boolean;
4
+ import type { ServerRuntime } from "./runtime.js";
5
+ /** Why the sample project wasn't added, as a fixed key in the address (never words from it). */
6
+ export declare const SAMPLE_PROBLEMS: {
7
+ readonly "no-root": "There's no project folder to put the sample in. Add a folder instead.";
8
+ readonly exists: "A folder named toolroll-sample is already there and wasn't made here, so nothing was changed.";
9
+ readonly failed: "The sample project couldn't be made. Nothing was added. Try again, or add a folder instead.";
10
+ readonly "not-added": "The sample project was made but couldn't be added. Try again, or add it from Projects.";
10
11
  };
11
- /** The five steps as the page shows them, and which one leads. */
12
- export declare function welcomeSteps(facts: WelcomeFacts): {
13
- steps: CheckStep[];
14
- primary: {
15
- label: string;
16
- href: string;
17
- } | null;
12
+ export type SampleProblem = keyof typeof SAMPLE_PROBLEMS;
13
+ /** The page body for one model, and the screen's one primary (for the phone dock). `problem`: why the sample project
14
+ * wasn't added, from the address. `canAdd`: this person may add projects here. */
15
+ export declare function welcomeBody(model: WelcomeModel, options: {
16
+ problem?: SampleProblem | null;
17
+ canAdd: boolean;
18
+ }): {
19
+ body: Html;
20
+ primary: Html | null;
18
21
  };
19
- export declare function welcomePage(chrome: Chrome, facts: WelcomeFacts): Screen;
22
+ export declare function welcomePage(chrome: Chrome, model: WelcomeModel, options: {
23
+ problem?: SampleProblem | null;
24
+ canAdd: boolean;
25
+ }): Screen;
20
26
  export declare function createWelcomeHandlers(runtime: ServerRuntime): {
21
27
  registrations: import("./handler-registry.js").Registration[];
22
28
  };
@@ -1,69 +1,167 @@
1
1
  /**
2
- * The first run's own page, /welcome (Wave E, src/design/KIT.md): five guided steps to a first result, each derived
3
- * from live facts on every render (never a stored cursor), each with the one action that does it. The next required
4
- * step's action is the screen's one primary (on a fresh install: Add a project); alerts on the phone are optional and
5
- * never lead. It only reads: every step's action is a link to the page that does it. Once the first result has
6
- * arrived (or for a project-limited account, or in the demo) the first run is over and the address leads Home.
2
+ * The first run's own page, /welcome (src/design/KIT.md): three steps to a first result, Agent · Project · First task,
3
+ * told by first-run.ts's one model (welcomeModel) from live facts on every render, never a stored cursor. The page
4
+ * reads; its acts are the ones that already own each change: choosing the agent posts to Settings → Lead's own
5
+ * turn-on (/settings/lead/on), a folder and GitHub link to Projects, and the sample project is made and added here
6
+ * (POST /welcome/sample) the way Projects adds a folder (project-admission.ts). A first task's starter only drafts its
7
+ * words into New task's box: nothing is filed until the person sends it. Once the first result has arrived (or for a
8
+ * project-limited account, or in the demo) the address leads Home.
7
9
  */
10
+ import { resolvePhaseAgent } from "../agentconfig.js";
11
+ import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
8
12
  import { builderReadyFor, firstRunBuilderRepo } from "../dispatch.js";
9
- import { withBuilderStep } from "../first-run.js";
10
- import { html } from "../html.js";
13
+ import { firstTaskSuggestions, SAMPLE_FIRST_TASKS, SAMPLE_PROJECT_NAME, welcomeModel, withBuilderStep } from "../first-run.js";
14
+ import { html, joinHtml, postForm } from "../html.js";
11
15
  import { START_BUILDER_HREF } from "../needs-you.js";
16
+ import { admitProject } from "../project-admission.js";
17
+ import { authorizedProject } from "../project.js";
18
+ import { createSampleProject, registerSampleCheck } from "../sample-project.js";
19
+ import { refuse, screen } from "./chrome.js";
12
20
  import { handlersOf } from "./handler-registry.js";
13
21
  import { redirect } from "./http.js";
14
- import { btn, checklist, dock, pageHeader } from "./kit.js";
15
- import { screen } from "./chrome.js";
22
+ import { btn, chip, codeLine, dock, note, notice, pageHeader, settingsRecord } from "./kit.js";
23
+ /** Why the sample project wasn't added, as a fixed key in the address (never words from it). */
24
+ export const SAMPLE_PROBLEMS = {
25
+ "no-root": "There's no project folder to put the sample in. Add a folder instead.",
26
+ exists: `A folder named ${SAMPLE_PROJECT_NAME} is already there and wasn't made here, so nothing was changed.`,
27
+ failed: "The sample project couldn't be made. Nothing was added. Try again, or add a folder instead.",
28
+ "not-added": "The sample project was made but couldn't be added. Try again, or add it from Projects.",
29
+ };
30
+ const isSampleProblem = (value) => value !== null && Object.hasOwn(SAMPLE_PROBLEMS, value);
16
31
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
17
- /** The five steps as the page shows them, and which one leads. */
18
- export function welcomeSteps(facts) {
19
- const step = (key) => facts.steps.find(one => one.key === key);
20
- const agent = step("agent"), builder = step("builder"), task = step("task");
21
- const projectDone = facts.projects > 0;
22
- const agentDone = (agent?.done ?? false) && (builder?.done ?? true);
23
- const command = agent?.action?.kind === "command" ? agent.action.command : null;
24
- const agentState = agentDone ? "done" : agent?.checking === true ? "checking" : "open";
25
- const agentText = agentDone ? "An agent is signed in and a builder is ready."
26
- : agent?.checking === true ? "Checking for a signed-in agent on this computer…"
27
- : agent !== undefined && !agent.done && command !== null ? html `On the computer running Toolroll, run <code class="k-mono">${command}</code>.`
28
- : "Start a builder so tasks can run.";
29
- const agentLink = agentDone || agent?.checking === true ? null
30
- : agent !== undefined && !agent.done ? { label: "Agent settings", href: "/settings/models" } : { label: "Start a builder", href: START_BUILDER_HREF };
31
- const taskDone = task?.done ?? false;
32
- // The next required step leads (alerts are optional and never do).
33
- const order = [
34
- { key: "project", open: !projectDone, link: { label: "Add a project", href: "/projects" } },
35
- { key: "agent", open: agentState === "open", link: agentLink },
36
- { key: "task", open: !taskDone && projectDone, link: { label: "New task", href: "/tasks/new" } },
37
- ];
38
- const lead = order.find(one => one.open && one.link !== null) ?? null;
39
- const action = (key, link) => link === null ? null : btn(link.label, { sm: true, href: link.href, ...(lead?.key === key ? { variant: "primary", docked: true } : {}) });
40
- const steps = [
41
- { key: "password", title: "Your password", text: "You're signed in. Your first login is saved on the computer running Toolroll.", state: "done" },
42
- { key: "project", title: "Add a project", text: projectDone ? plural(facts.projects, "project added.", "projects added.") : "A git repository agents work in.", state: projectDone ? "done" : "open", action: action("project", order[0].link) },
43
- { key: "agent", title: "Agent and models", text: agentText, state: agentState, action: action("agent", agentLink) },
44
- { key: "phone", title: "Alerts on your phone", text: facts.alerts ? "Alerts are on." : "Optional. Hear when something needs you.", state: facts.alerts ? "done" : "open", action: btn("Set up alerts", { sm: true, href: "/settings/alerts#this-device" }) },
45
- { key: "task", title: "Your first task", text: taskDone ? "Filed. Its plan waits for your approval." : "Describe a change and approve its plan.", state: taskDone ? "done" : "open",
46
- action: projectDone ? action("task", order[2].link) : null },
47
- ];
48
- return { steps, primary: lead?.link ?? null };
32
+ const AGENT_WORDS = { "signed-in": "Signed in", "signed-out": "Not signed in", "not-installed": "Not installed", checking: "Checking…" };
33
+ const PROJECTS_SHOWN = 3;
34
+ const ADD_FOLDER = "/projects?return=%2Fwelcome";
35
+ /** One step: its number and name, Done once it is, then its body. */
36
+ function step(key, index, title, done, body) {
37
+ return html `<section class="k-sec welcome-step" id="welcome-${key}" data-step="${key}" data-done="${done ? "1" : "0"}" aria-labelledby="welcome-${key}-h">\
38
+ <div class="k-secthead"><h3 id="welcome-${key}-h">${index}. ${title}</h3>${done ? chip("Done", "done") : ""}</div>${body}</section>`;
49
39
  }
50
- export function welcomePage(chrome, facts) {
51
- const { steps, primary } = welcomeSteps(facts);
52
- const left = steps.filter(one => one.state !== "done").length;
53
- const body = html `${pageHeader("Welcome")}<div class="k-doc"><div class="k-hello"><h2>Get to your first result.</h2><p>${left === 0 ? "Setup is done." : `${plural(left, "step", "steps")} left.`}</p></div>\
54
- ${checklist(steps, "Setup steps")}</div>${dock(primary === null ? null : btn(primary.label, { variant: "primary", href: primary.href }))}`;
55
- return screen("Welcome", body, { chrome });
40
+ /** "Use this one": Settings → Lead's own turn-on, for exactly this agent, back here. */
41
+ const useForm = (agent, button) => postForm("/settings/lead/on", button, { hidden: { agent, return: "/welcome" } });
42
+ function agentRow(agent, leads) {
43
+ const sub = agent.command === null ? AGENT_WORDS[agent.state]
44
+ : html `${AGENT_WORDS[agent.state]}. To sign in, run this on the computer running Toolroll:${codeLine(agent.command)}`;
45
+ const action = agent.inUse ? chip("In use", "", { dot: false })
46
+ : agent.canUse ? useForm(agent.id, btn("Use this one", { sm: true, type: "submit", ...(leads ? { variant: "primary", docked: true } : {}) }))
47
+ : "";
48
+ return settingsRecord({ title: agent.name, sub, action, attrs: { "data-agent": agent.id, "data-state": agent.state } });
56
49
  }
50
+ /** The page body for one model, and the screen's one primary (for the phone dock). `problem`: why the sample project
51
+ * wasn't added, from the address. `canAdd`: this person may add projects here. */
52
+ export function welcomeBody(model, options) {
53
+ const lead = model.next;
54
+ let primary = null;
55
+ const agentLead = lead === "agent" ? model.agent.agents.find(one => one.canUse) ?? null : null;
56
+ if (agentLead !== null)
57
+ primary = useForm(agentLead.id, btn(`Use ${agentLead.name}`, { variant: "primary", type: "submit" }));
58
+ const agentNote = model.agent.checking ? note("Checking for a signed-in agent on this computer…")
59
+ : !model.agent.done ? note("Sign in to one of these, then come back to this page.")
60
+ : model.agent.buildsWith !== null ? note(html `Tasks are built with ${model.agent.buildsWith}. <a class="k-link" href="/settings/models">Change</a>`) : "";
61
+ const agent = step("agent", 1, "Agent", model.agent.done, html `<p class="k-note">The AI coding agent that plans and builds your tasks.</p>\
62
+ <div class="welcome-rows">${model.agent.agents.map(one => agentRow(one, agentLead?.id === one.id))}</div>${agentNote}`);
63
+ const projects = model.project.projects;
64
+ const listed = projects.slice(0, PROJECTS_SHOWN).map(one => settingsRecord({ title: one.name, sub: html `<span class="k-mono">${one.path}</span>`, attrs: { "data-project": "" } }));
65
+ const more = projects.length > PROJECTS_SHOWN ? note(html `And ${plural(projects.length - PROJECTS_SHOWN, "more project", "more projects")} in <a class="k-link" href="/projects">Projects</a>.`) : "";
66
+ const working = lead === "project" && model.project.notTakingWork;
67
+ const folderLeads = lead === "project" && !working;
68
+ const folderLabel = projects.length === 0 ? "Add a folder" : "Add another folder";
69
+ if (folderLeads)
70
+ primary = btn(folderLabel, { variant: "primary", href: ADD_FOLDER });
71
+ if (working)
72
+ primary = btn("Start taking work", { variant: "primary", href: START_BUILDER_HREF });
73
+ const adds = joinHtml([
74
+ model.project.notTakingWork ? btn("Start taking work", { sm: true, href: START_BUILDER_HREF, ...(working ? { variant: "primary", docked: true } : {}) }) : "",
75
+ btn(folderLabel, { sm: true, href: ADD_FOLDER, ...(folderLeads ? { variant: "primary", docked: true } : {}) }),
76
+ options.canAdd ? btn("From GitHub", { sm: true, href: "/projects/github" }) : "",
77
+ options.canAdd && model.project.sample !== null ? postForm("/welcome/sample", btn("Try a sample project", { sm: true, type: "submit" }), { attrs: { class: "welcome-form" } }) : "",
78
+ ]);
79
+ const projectLine = projects.length === 0 ? "A git repository on this computer for agents to work in."
80
+ : model.project.notTakingWork ? `${plural(projects.length, "project added", "projects added")}. Tasks can't start yet: this computer isn't taking work for them.`
81
+ : plural(projects.length, "project added.", "projects added.");
82
+ const sampleWhere = projects.length === 0 && options.canAdd && model.project.sample !== null
83
+ ? note(html `The sample is a tiny repository, made at <span class="k-mono">${model.project.sample}</span>.`) : "";
84
+ const project = step("project", 2, "Project", model.project.done, html `<p class="k-note">${projectLine}</p>\
85
+ ${listed.length === 0 ? "" : html `<div class="welcome-rows">${listed}</div>`}${more}${options.problem ? notice(SAMPLE_PROBLEMS[options.problem]) : ""}\
86
+ <div class="k-acts welcome-acts">${adds}</div>${sampleWhere}`);
87
+ const taskLeads = lead === "task";
88
+ if (taskLeads)
89
+ primary = btn("Write a task", { variant: "primary", href: "/tasks/new" });
90
+ const taskBody = model.task.done ? html `<p class="k-note">Filed. Its plan waits for your approval in <a class="k-link" href="/work">Tasks</a>.</p>`
91
+ : !model.task.open ? html `<p class="k-note">Add a project first.</p>`
92
+ : html `<p class="k-note">Describe a change in a sentence or two. Nothing starts until you approve its plan.</p>\
93
+ ${model.task.starters.length === 0 ? "" : html `<div class="k-ideas" aria-label="Ideas to start from" data-starters>${model.task.starters.map(one => html `<a class="k-btn k-btn-outline k-idea" href="${one.href}">${one.label}</a>`)}</div>`}\
94
+ <div class="k-acts">${btn("Write a task", { sm: true, href: "/tasks/new", ...(taskLeads ? { variant: "primary", docked: true } : {}) })}</div>`;
95
+ const task = step("task", 3, "First task", model.task.done, taskBody);
96
+ const hello = html `<div class="k-hello"><h2>Get to your first result.</h2><p>${model.left === 0 ? "Setup is done." : `${plural(model.left, "step", "steps")} left.`}</p></div>`;
97
+ return { body: html `${hello}${agent}${project}${task}`, primary };
98
+ }
99
+ export function welcomePage(chrome, model, options) {
100
+ const { body, primary } = welcomeBody(model, options);
101
+ return screen("Welcome", html `${pageHeader("Welcome")}<div class="k-doc welcome" data-welcome>${body}</div>${dock(primary)}`, { chrome });
102
+ }
103
+ const AGENT_OF = { "claude-subscription": "claude", "codex-subscription": "codex" };
57
104
  export function createWelcomeHandlers(runtime) {
58
- const { store, sendScreen, chromeFor, firstRunStepsNow, managedRepos, visible } = runtime;
105
+ const { store, sendScreen, chromeFor, firstRunStepsNow, managedRepos, visible, liveCeiling, identities, deletedRepos, options, sessions } = runtime;
59
106
  async function showWelcome(ctx) {
60
- const { who, response, now, project } = ctx;
107
+ const { who, response, now, project, url } = ctx;
61
108
  const steps = withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now));
62
109
  // The first run is over (a first result, a project-limited account, the demo): the address leads Home.
63
110
  if (steps === null)
64
111
  return redirect(response, "/");
65
- const alerts = who.via === "cookie" && store.listPushSubscriptions(who.name).some(one => one.retiredAt === null);
66
- return sendScreen(response, 200, welcomePage(chromeFor(project, "work"), { steps, projects: managedRepos().length, alerts }));
112
+ // The projects the sidebar lists (a sample added here included).
113
+ const chrome = chromeFor(project, "work");
114
+ const projects = (chrome.projects ?? []).map(one => ({ name: one.name, path: one.path }));
115
+ const repo = project ?? projects[0]?.path ?? null;
116
+ const root = liveCeiling().roots[0] ?? null;
117
+ const sample = root === null ? null : `${root.replace(/\/+$/, "")}/${SAMPLE_PROJECT_NAME}`;
118
+ const starters = repo === null ? [] : repo === sample ? SAMPLE_FIRST_TASKS : runtime.firstTasks.get(repo)?.found ?? firstTaskSuggestions({ issues: [], todos: [] });
119
+ const states = runtime.localSignIn?.states ?? null;
120
+ const build = resolvePhaseAgent(store, "build", repo, {});
121
+ const approver = who.via === "cookie" && who.role === "approver";
122
+ const model = welcomeModel({
123
+ agents: { claude: states?.claude ?? null, codex: states?.codex ?? null },
124
+ inUse: AGENT_OF[store.getChatConfig()?.provider ?? ""] ?? null,
125
+ buildsOn: build.ok ? build.spec.provider : null,
126
+ canChoose: approver,
127
+ projects, projectRoot: root,
128
+ ready: steps.find(one => one.key === "builder")?.done ?? false,
129
+ hasTask: steps.find(one => one.key === "task")?.done ?? false,
130
+ starters, platform: options.platform ?? process.platform,
131
+ });
132
+ const asked = url.searchParams.get("sample");
133
+ return sendScreen(response, 200, welcomePage(chrome, model, { problem: isSampleProblem(asked) ? asked : null, canAdd: approver && !store.isDemo() }));
134
+ }
135
+ /** Try a sample project: made in the project folder (or found again when it is ours), then added the way Projects
136
+ * adds a folder, and opened. A refusal comes back to the welcome as a fixed key. */
137
+ async function addSample(ctx) {
138
+ const { who, response, now, posted } = ctx;
139
+ readForm(posted, CONSOLE_FORMS.welcomeSample);
140
+ if (who.via !== "cookie")
141
+ return refuse(response, who, 403, "adding a project is a browser session's act");
142
+ if (store.isDemo())
143
+ return refuse(response, who, 403, "the sandbox never makes projects");
144
+ const back = (problem) => redirect(response, `/welcome?sample=${problem}#welcome-project`);
145
+ const made = await createSampleProject(liveCeiling().roots[0] ?? null);
146
+ if (!made.ok)
147
+ return back(made.reason);
148
+ // Added exactly as Projects adds a folder: inside what this console serves, visible to this person, a git repository.
149
+ const decidedOn = identities.generation();
150
+ const canonical = await identities.resolveNow(made.path);
151
+ if (canonical === null || canonical === "no-answer" || !(await authorizedProject(liveCeiling(), canonical)) || !visible(canonical))
152
+ return back("not-added");
153
+ const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
154
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
155
+ if (!admitted.ok || !stillAllowed(admitted.repo))
156
+ return back("not-added");
157
+ // Its own fixed check (sample-project.ts), so the first result reads checked rather than "Checks not run": only for a
158
+ // sample this request just made, never one found again.
159
+ registerSampleCheck(store, made, admitted.repo, who.name, now);
160
+ deletedRepos.delete(admitted.repo);
161
+ who.session.project = admitted.repo;
162
+ who.session.projectRevision++;
163
+ sessions.persist(who.session);
164
+ return redirect(response, "/welcome");
67
165
  }
68
- return { registrations: handlersOf("tasks", { welcome: showWelcome }) };
166
+ return { registrations: handlersOf("tasks", { welcome: showWelcome, "welcome.sample": addSample }) };
69
167
  }
@@ -134,6 +134,13 @@ export type SettingsFacts = {
134
134
  failed: boolean;
135
135
  words: string;
136
136
  } | null;
137
+ /** Easy-start: local mode is on for this laptop install. `owner`: this is the local owner's own session (no password);
138
+ * `passwordSet`: the owner has set one; `canTurnOff`: a full session of an instance operator. Absent when it is off. */
139
+ local?: {
140
+ owner: boolean;
141
+ passwordSet: boolean;
142
+ canTurnOff: boolean;
143
+ } | null;
137
144
  };
138
145
  declare const groupHref: (slug: string) => string;
139
146
  export type NeedsItem = {
@@ -146,7 +153,6 @@ export type NeedsItem = {
146
153
  export declare function settingsNeeds(facts: SettingsFacts): NeedsItem[];
147
154
  /** Which groups have something waiting, for the section nav's dot and the overview's rows. */
148
155
  export declare function needsByGroup(items: readonly NeedsItem[]): Record<string, number>;
149
- /** The overview: what waits on this person, then one row per group with its state. Viewers see the groups they can open. */
150
156
  export declare function settingsHub(facts: SettingsFacts): Html;
151
157
  /** The hub's old anchors, forwarded to their group (fragments never reach the server). */
152
158
  export declare const HUB_ANCHORS: Readonly<Record<string, string>>;
@@ -84,6 +84,26 @@ function systemWords(facts) {
84
84
  return [facts.backup?.failed ? "The last backup failed" : null, version, "backups, storage, retention, monitoring"].filter(Boolean).join(" · ");
85
85
  }
86
86
  /** The overview: what waits on this person, then one row per group with its state. Viewers see the groups they can open. */
87
+ /**
88
+ * Easy-start C5: local mode, said once. The local owner without a password is offered the one way to more ("Let agents
89
+ * work on their own" → /password/set); a full session of an operator sees it on, and can turn it off (a step-up, confirmed).
90
+ */
91
+ function localModeSection(facts) {
92
+ const local = facts.local;
93
+ if (local === undefined || local === null)
94
+ return html ``;
95
+ if (local.owner) {
96
+ return local.passwordSet ? html `` : settingsSection({ id: "set-password", title: "Let agents work on their own",
97
+ lead: "Agents can work on their own, merge and release only after you set a password.",
98
+ body: html `<div class="k-acts">${btn("Set a password", { sm: true, href: "/password/set?next=%2Fsettings" })}</div>` });
99
+ }
100
+ if (!local.canTurnOff)
101
+ return html ``;
102
+ return settingsSection({ id: "local-mode", title: "Local mode", state: " · on",
103
+ lead: "A browser on this computer can file tasks, approve plans and add projects without your password.",
104
+ body: kitSectionForm({ proof: "local-mode-off", action: "/settings/local-mode/off", id: "local-mode-form", save: { label: "Turn off", variant: "outline" },
105
+ confirm: "Turn off local mode", body: html `` }) });
106
+ }
87
107
  export function settingsHub(facts) {
88
108
  const items = settingsNeeds(facts);
89
109
  const counts = needsByGroup(items);
@@ -104,6 +124,7 @@ export function settingsHub(facts) {
104
124
  body: joinHtml([
105
125
  answered(facts),
106
126
  panel,
127
+ facts.local?.owner === true ? localModeSection(facts) : "",
107
128
  settingsLinks(null, shown.map(([label, slug, href]) => settingsLink({ href, title: label, status: words[slug] ?? "", icon: SETTINGS_GROUP_ICONS[slug], needs: counts[slug] ?? 0 }))),
108
129
  ]),
109
130
  });
@@ -144,7 +165,7 @@ export function generalGroup(facts, flags) {
144
165
  id: "your-password", title: "Your password",
145
166
  body: joinHtml([
146
167
  formRow("Where it is", "If you started Toolroll with toolroll up, your password is saved on the computer running Toolroll. Toolroll never shows it in a browser. To read it, run this there:", html ``, "wide"),
147
- codeLine("cat ~/.config/toolroll/up-login.txt"),
168
+ codeLine("cat ~/.config/toolroll/.toolroll-secrets/up-login.txt"),
148
169
  facts.passwordless === null ? "" : formRow("Passwordless", "Sign in once; most approvals then need no password.", btn(`${facts.passwordless ? "On" : "Off"} · Change`, { sm: true, href: "/settings/passwordless" }), "inline"),
149
170
  ]),
150
171
  });
@@ -422,7 +443,7 @@ export function accessGroup(facts, flags) {
422
443
  ] : []),
423
444
  ];
424
445
  return settingsShell({ group: "access", title: "People and sign-in", lead: "Who can get in, how they sign in, and the ways in they hold.", flags,
425
- body: joinHtml([answered(facts), settingsLinks(null, links, { label: "People and sign-in" })]) });
446
+ body: joinHtml([answered(facts), localModeSection(facts), settingsLinks(null, links, { label: "People and sign-in" })]) });
426
447
  }
427
448
  function updatesSection(updates) {
428
449
  const latest = updates.latest;
@@ -67,6 +67,7 @@ export const PROOF_INVENTORY = [
67
67
  { id: "people-close", action: "Cancel an invite or remove a person", routes: ["/people/invite-revoke", "/people/revoke"], form: "peopleRevoke", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver", secrets: [], terms: [] },
68
68
  { id: "project-setup", action: "Approve project setup", routes: ["/control/setup-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project; models for a new installation need an instance operator", secrets: [], terms: ["repo", "nonce", "fingerprint", "provider", "model", "command", "seconds"], note: "the nonce is spent before the password is checked: after a refusal, review again; the reviewed inputs are previewed again and must match the fingerprint" },
69
69
  { id: "project-instructions", action: "Add the agent instructions", routes: ["/control/instructions-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "nonce", "fingerprint"], note: "the nonce is spent before the password is checked" },
70
+ { id: "local-mode-off", action: "Turn off local mode", routes: ["/settings/local-mode/off"], form: "localModeOff", field: "password", proof: "step-up", ...ACCEPT, role: "operator", handlerRole: "a full session (never the local owner)", secrets: [], terms: [] },
70
71
  { id: "mode-sign", action: "Sign an operating mode", routes: ["/mode/sign"], form: "mode", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
71
72
  { id: "mode-sign-chat-approve", action: "Sign an operating mode that lets your chat app approve", routes: ["/mode/sign"], select: { field: "chat-approve", value: "1" }, form: "mode", field: "token", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
72
73
  // Credential acts: a password typed now or a one-use re-check; never Passwordless.
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { validModelId } from "./provider.js";
8
8
  import { planInstall, applyInstall, skillContent, SKILL_DIR, SKILL_FILE } from "./skills.js";
9
+ import { folderAnswers } from "./folder-gate.js";
9
10
  export const ASSISTANTS = withAcp({
10
11
  claude: { name: "Claude", description: "Use Claude Code for your tasks." },
11
12
  codex: { name: "Codex", description: "Use your OpenAI coding assistant." },
@@ -37,6 +38,9 @@ export function modelChoices(provider, configured, home = homedir()) {
37
38
  return choices;
38
39
  }
39
40
  export function detectPreparation(repo) {
41
+ // In the console, a folder that hasn't answered a look lately is not read on its thread (folder-gate.ts).
42
+ if (!folderAnswers(repo))
43
+ return null;
40
44
  const has = (file) => existsSync(join(repo, file));
41
45
  if (has("package.json")) {
42
46
  if (has("pnpm-lock.yaml"))
@@ -59,6 +63,8 @@ export function detectPreparation(repo) {
59
63
  }
60
64
  /** Browser installation may write only this known file inside its project. */
61
65
  export function previewProjectInstructions(repo) {
66
+ if (!folderAnswers(repo))
67
+ return { ok: false, message: "This project folder didn't answer. Try again in a moment." };
62
68
  try {
63
69
  if (!lstatSync(repo).isDirectory())
64
70
  return { ok: false, message: "This project folder is unavailable. Reconnect it before adding instructions." };