toolroll 0.9.89 → 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 (136) hide show
  1. package/CHANGELOG.md +44 -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 +32 -32
  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-delivery.js +2 -0
  18. package/dist/chat-task-actions.d.ts +1 -0
  19. package/dist/check-access.d.ts +3 -0
  20. package/dist/check-access.js +4 -1
  21. package/dist/cli-help.d.ts +1 -1
  22. package/dist/cli-help.js +6 -2
  23. package/dist/cli.d.ts +6 -0
  24. package/dist/cli.js +23 -0
  25. package/dist/coding-ui.js +4 -2
  26. package/dist/connect-cli.js +2 -1
  27. package/dist/console-instance.d.ts +34 -1
  28. package/dist/console-instance.js +124 -15
  29. package/dist/contracts/cli.d.ts +72 -0
  30. package/dist/contracts/cli.js +13 -0
  31. package/dist/contracts/console-api.d.ts +9 -2
  32. package/dist/contracts/console-api.js +9 -2
  33. package/dist/desktop-host.js +8 -3
  34. package/dist/desktop-update.js +2 -1
  35. package/dist/dispatch.d.ts +3 -0
  36. package/dist/dispatch.js +22 -2
  37. package/dist/easy-start-args.d.ts +13 -0
  38. package/dist/easy-start-args.js +24 -0
  39. package/dist/easy-start.d.ts +77 -0
  40. package/dist/easy-start.js +321 -0
  41. package/dist/first-run.d.ts +77 -0
  42. package/dist/first-run.js +45 -0
  43. package/dist/guides.js +1 -1
  44. package/dist/invoke.js +11 -6
  45. package/dist/laptop-background.d.ts +171 -0
  46. package/dist/laptop-background.js +550 -0
  47. package/dist/laptop-service.d.ts +111 -0
  48. package/dist/laptop-service.js +268 -0
  49. package/dist/lead-cli.js +2 -1
  50. package/dist/lead-doors.d.ts +6 -0
  51. package/dist/lead-doors.js +20 -2
  52. package/dist/live-records.d.ts +1 -1
  53. package/dist/live-records.js +3 -12
  54. package/dist/local-owner.d.ts +124 -0
  55. package/dist/local-owner.js +212 -0
  56. package/dist/login-secrets.d.ts +60 -0
  57. package/dist/login-secrets.js +195 -0
  58. package/dist/mcp-oauth.d.ts +4 -0
  59. package/dist/mcp-oauth.js +13 -7
  60. package/dist/model-choices.d.ts +43 -0
  61. package/dist/model-choices.js +80 -0
  62. package/dist/operate.d.ts +46 -1
  63. package/dist/operate.js +461 -40
  64. package/dist/person-proof.js +3 -0
  65. package/dist/project-knowledge.d.ts +7 -7
  66. package/dist/quick-start.d.ts +3 -1
  67. package/dist/quick-start.js +6 -1
  68. package/dist/release-cli.js +2 -1
  69. package/dist/runner-process.d.ts +9 -0
  70. package/dist/runner-process.js +18 -0
  71. package/dist/sample-project.d.ts +31 -0
  72. package/dist/sample-project.js +141 -0
  73. package/dist/scope.d.ts +24 -2
  74. package/dist/scope.js +74 -3
  75. package/dist/serve.js +129 -22
  76. package/dist/server/chat.js +10 -3
  77. package/dist/server/chrome.d.ts +10 -2
  78. package/dist/server/chrome.js +7 -5
  79. package/dist/server/console.css +10 -0
  80. package/dist/server/guards.d.ts +3 -0
  81. package/dist/server/guards.js +22 -2
  82. package/dist/server/http.d.ts +22 -0
  83. package/dist/server/http.js +58 -0
  84. package/dist/server/live.js +4 -14
  85. package/dist/server/mutation-key.d.ts +70 -0
  86. package/dist/server/mutation-key.js +142 -0
  87. package/dist/server/pages.js +10 -7
  88. package/dist/server/people-tokens.js +240 -20
  89. package/dist/server/remote.d.ts +2 -0
  90. package/dist/server/remote.js +19 -4
  91. package/dist/server/render-new-task.js +5 -1
  92. package/dist/server/render-pages.d.ts +2 -1
  93. package/dist/server/render-pages.js +1 -1
  94. package/dist/server/render-people.d.ts +36 -0
  95. package/dist/server/render-people.js +65 -1
  96. package/dist/server/render-projects.js +4 -2
  97. package/dist/server/render-tasks.d.ts +12 -0
  98. package/dist/server/render-tasks.js +12 -4
  99. package/dist/server/request-context.d.ts +6 -0
  100. package/dist/server/route-table.d.ts +16 -0
  101. package/dist/server/route-table.js +121 -0
  102. package/dist/server/runtime.d.ts +6 -4
  103. package/dist/server/session.d.ts +31 -1
  104. package/dist/server/session.js +12 -2
  105. package/dist/server/settings.js +63 -8
  106. package/dist/server/task-views.js +10 -1
  107. package/dist/server/tasks.d.ts +10 -0
  108. package/dist/server/tasks.js +59 -9
  109. package/dist/server/terminals.js +15 -2
  110. package/dist/server/welcome.d.ts +22 -16
  111. package/dist/server/welcome.js +150 -52
  112. package/dist/settings-groups-ui.d.ts +7 -1
  113. package/dist/settings-groups-ui.js +23 -2
  114. package/dist/settings-proofs.js +1 -0
  115. package/dist/setup-guide.d.ts +2 -2
  116. package/dist/store.d.ts +124 -1
  117. package/dist/store.js +245 -12
  118. package/dist/surface.d.ts +10 -0
  119. package/dist/surface.js +40 -1
  120. package/dist/task-text.d.ts +6 -0
  121. package/dist/task-text.js +17 -0
  122. package/dist/telegram-lead.js +2 -0
  123. package/dist/terminal-text.d.ts +11 -0
  124. package/dist/terminal-text.js +26 -0
  125. package/dist/toolroll-update.d.ts +8 -1
  126. package/dist/toolroll-update.js +151 -4
  127. package/dist/tui-contract.d.ts +118 -0
  128. package/dist/tui-contract.js +9 -0
  129. package/dist/tui-source.d.ts +14 -0
  130. package/dist/tui-source.js +182 -0
  131. package/dist/tui.js +129 -0
  132. package/dist/up-processes.d.ts +13 -1
  133. package/dist/up-processes.js +13 -3
  134. package/dist/welcome-facts.d.ts +18 -0
  135. package/dist/welcome-facts.js +79 -0
  136. package/package.json +4 -2
@@ -517,6 +517,8 @@ export type BrowserTaskView = {
517
517
  /** When a remembered password stops counting (ISO); the page words it in the viewer's zone. */
518
518
  rememberedUntil: string | null;
519
519
  planHtml: string;
520
+ /** The terms line in words, when the rule isn't one of the kit's (easy-start C5: a laptop's own owner). */
521
+ termsWords?: string;
520
522
  } | null;
521
523
  /** Ship: the pull request's checks, each on one time axis (task ship's twin). Null: no checks read yet. */
522
524
  ship?: {
@@ -1628,6 +1630,15 @@ export type BrowserWorkspace = {
1628
1630
  phone?: BrowserPhoneCard;
1629
1631
  /** The Chat landing's live view: who is working now, four counts, and Catch up in tabs. */
1630
1632
  home?: BrowserHome | null;
1633
+ /** Easy-start C2: this tab's mutation key is from an earlier Toolroll start (or it has none): it reads, but needs to
1634
+ * reconnect before it can change anything (the password again, or `toolroll open` for a local owner). */
1635
+ reconnect?: {
1636
+ kind: 'password';
1637
+ href: string;
1638
+ } | {
1639
+ kind: 'command';
1640
+ command: string;
1641
+ };
1631
1642
  };
1632
1643
  /** Home ("/"): one sentence that answers "what needs me?", four counts that agree with the Tasks filters they open,
1633
1644
  * what waits on this person grouped by what it asks, what is running, and the selected item answered in place. */
@@ -1669,6 +1680,8 @@ export type BrowserHomeView = {
1669
1680
  title: string;
1670
1681
  text: string;
1671
1682
  } | null;
1683
+ /** While the first run is open (no first result yet): the welcome's address, linked as Finish setup. */
1684
+ setup?: string | null;
1672
1685
  };
1673
1686
  export type BrowserHomeRow = {
1674
1687
  id: string;
package/dist/builder.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { legacyNetwork, repositoryEcosystems } from "./egress.js";
2
- import { CHECK_ACCESS_DEFAULT, checkAccessHere, checkNetworkOf } from "./check-access.js";
2
+ import { CHECK_ACCESS_DEFAULT, checkAccessHere, checkNetworkOf, localModeCap } from "./check-access.js";
3
+ import { localModeOn } from "./local-owner.js";
3
4
  import { checkoutGit } from "./teardown-git.js";
4
5
  import { isAcpProvider } from "./acp-agents.js";
5
6
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
@@ -337,7 +338,9 @@ function commandRunner(store, runId, profile, inner) {
337
338
  * run sandboxed while sign-ins are off, and the run is stamped with that, what actually ran), and under sign-ins the network its
338
339
  * approval sealed less its agent's own provider hosts (never its checks'). */
339
340
  export function checkLaunchOf(store, runId, profile, platform = process.platform) {
340
- const access = checkAccessHere(profile.checkAccess ?? CHECK_ACCESS_DEFAULT, platform);
341
+ // Easy-start C5: while local mode is on, a sealed unsandboxed level launches with at most sign-ins (re-read here, at launch).
342
+ const asked = checkAccessHere(profile.checkAccess ?? CHECK_ACCESS_DEFAULT, platform);
343
+ const access = localModeOn(store) ? localModeCap(asked) : asked;
341
344
  if (access !== "sign-ins")
342
345
  return { access, network: null };
343
346
  const taskRef = store.getRun(runId)?.taskRef;
@@ -1,3 +1,6 @@
1
+ /** Shared, exact-state actions. Models may prepare; only the existing human
2
+ * confirmation door executes. Protected actions use a one-use review receipt. */
3
+ import { type Authority } from "./local-owner.js";
1
4
  import { type Store } from "./store.js";
2
5
  import { type VerifiedApprover } from "./principal.js";
3
6
  import { CHAT_ACTION_FIELDS, type SharedAction } from "./contracts/chat-actions.js";
@@ -292,6 +295,8 @@ export declare function mintSharedActionReview(store: Store, who: VerifiedApprov
292
295
  };
293
296
  export type SharedActionOptions = {
294
297
  via: "web" | "cli" | "telegram" | "slack" | "discord" | "teams";
298
+ /** Easy-start C5: a laptop's local owner confirms (the door decided the operation is one of its acts). */
299
+ authority?: Authority;
295
300
  root?: string;
296
301
  review?: {
297
302
  nonce: string;
@@ -1,5 +1,6 @@
1
1
  /** Shared, exact-state actions. Models may prepare; only the existing human
2
2
  * confirmation door executes. Protected actions use a one-use review receipt. */
3
+ import { CHAT_ACTION_LOCAL_ACTS, currentLocalOwner } from "./local-owner.js";
3
4
  import { fileTaskProposal } from "./proposal.js";
4
5
  import { heuristicSizing } from "./task-sizing.js";
5
6
  import { cancelSweepRest, sweepOpen, sweepTable, VERDICT_RULE } from "./lead-sweep.js";
@@ -13,7 +14,7 @@ import { leadTurnKeepsProposals, verifiedAuthor } from "./store.js";
13
14
  import { isVerifiedApprover, reproveApprover, } from "./principal.js";
14
15
  import { changeSkills, importSkill, skillsView, skillsVersion, testSkill, validateSkill, } from "./project-skills.js";
15
16
  import { changeKnowledge, knowledgeVersion, knowledgeView, } from "./project-knowledge.js";
16
- import { passwordlessSessionFor, approve, authenticateApprover, describeScope, scopeAuthorityOf, freshnessFor, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, } from "./scope.js";
17
+ import { passwordlessSessionFor, approve, authenticateApprover, describeScope, scopeAuthorityOf, freshnessFor, localApprovalEligible, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, } from "./scope.js";
17
18
  import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
18
19
  import { readAuthModeStrict } from "./keys.js";
19
20
  import { acceptAndCompleteAsOperator, assignmentOf } from "./assignment.js";
@@ -1114,6 +1115,9 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1114
1115
  const refuse = (reason, said) => ({ ok: false, reason, said });
1115
1116
  if (store.isDemo())
1116
1117
  return refuse("refused", "The demo authorizes nothing.");
1118
+ // Easy-start C1, a belt under the confirm door's own gate: a local owner runs only an operation that is one of its acts.
1119
+ if ((options.authority === "local-owner" || currentLocalOwner() !== null) && (!isChatAction(payload.operation) || CHAT_ACTION_LOCAL_ACTS[payload.operation] === null))
1120
+ return refuse("refused", "Set a password to do this.");
1117
1121
  let live;
1118
1122
  try {
1119
1123
  const saved = savedActionContext(store, who, id, "confirming");
@@ -1152,6 +1156,8 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1152
1156
  if (config.password &&
1153
1157
  (emptyApproval
1154
1158
  ? !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null
1159
+ // Easy-start C5: a local owner's eligible approval takes no password (approve() proves it again in the seal).
1160
+ && !(options.authority === "local-owner" && currentLocalOwner()?.act === "approve-plan" && typeof req["task"] === "string" && localApprovalEligible(store, who.name, req["task"]).ok)
1155
1161
  : proved?.ok !== true))
1156
1162
  return refuse("needs-confirm", "Confirm with your password in the secure screen.");
1157
1163
  }
@@ -652,6 +652,8 @@ export function applyChatAction(options, event, binding, repos) {
652
652
  }
653
653
  else {
654
654
  const outcome = confirmLeadProposal(store, verified.who, proposal.id, now, {
655
+ // A paired chat seat speaks for its account's full authority (pairing is a full session's act).
656
+ authority: "full",
655
657
  via: options.state.provider,
656
658
  evidenceRoot: options.evidenceRoot,
657
659
  confirm: phase === "yes",
@@ -39,6 +39,7 @@ export declare const CHAT_TASK_ACTIONS: {
39
39
  readonly detail: "Files a small task to write tests for this change. Nothing runs until it is approved.";
40
40
  };
41
41
  };
42
+ export type ChatTaskAction = keyof typeof CHAT_TASK_ACTIONS;
42
43
  export declare function isChatTaskAction(value: unknown): value is keyof typeof CHAT_TASK_ACTIONS;
43
44
  /** Bind cards to a specific execution and its current state, not the family's moving pointer. */
44
45
  export declare function chatTaskStamp(store: Store, who: VerifiedApprover, task: string): string | null;
@@ -48,4 +48,7 @@ export declare function checkAccessRunsAs(level: CheckAccess, here: {
48
48
  platform: NodeJS.Platform;
49
49
  fenced: boolean;
50
50
  unconfinedAllowed: boolean;
51
+ localMode?: boolean;
51
52
  }): CheckAccess;
53
+ /** Easy-start C5: while local mode is on, a project's checks reach at most its sign-ins, never unsandboxed. */
54
+ export declare const localModeCap: (level: CheckAccess) => CheckAccess;
@@ -73,5 +73,8 @@ export const checkAccessHere = (level, platform = process.platform) => level ===
73
73
  * on this machine and unconfined running allowed by an operator, without a sandbox whatever is saved (never said to be
74
74
  * sandboxed); otherwise the saved level as this platform can give it. */
75
75
  export function checkAccessRunsAs(level, here) {
76
- return !here.fenced && here.unconfinedAllowed ? "unsandboxed" : checkAccessHere(level, here.platform);
76
+ const runs = !here.fenced && here.unconfinedAllowed ? "unsandboxed" : checkAccessHere(level, here.platform);
77
+ return here.localMode === true ? localModeCap(runs) : runs;
77
78
  }
79
+ /** Easy-start C5: while local mode is on, a project's checks reach at most its sign-ins, never unsandboxed. */
80
+ export const localModeCap = (level) => level === "unsandboxed" ? "sign-ins" : level;
@@ -2,4 +2,4 @@
2
2
  * The top-level usage text, on its own so `toolroll --help` can print it
3
3
  * without loading every command family (see cli-fast.ts).
4
4
  */
5
- export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll in a repository: start Toolroll for it, or open the one already running\n (under an agent or with --json: only says what it would do)\n toolroll scan [path...] report what is in flight (also bare `toolroll` outside a repository)\n toolroll [path...] the same report for the paths given\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> add a project; it is in the console at once (no path: the repo you are in)\n toolroll repos remove <path>\n take it out of the console; its tasks and results stay\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll release <branch> release Toolroll itself: gate, your approval, deploy, merge, tag, publish\n (resumable: rerun to continue; --help)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll health recent server latency, write waits and live streams\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / worker release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to your lead: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll doctor check that Toolroll's service holds no key a build could read\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
5
+ export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll at your terminal: start Toolroll here (or attach) and open it in the terminal\n (--web: the browser; --project-root <dir>: where projects live)\n in a repository without a terminal, under an agent or with --json: only says what it would do\n toolroll open [--web] open this computer's Toolroll again, signed in\n toolroll welcome the three steps to a first result (--json: the model)\n toolroll password set add a password (typed twice, at your terminal)\n toolroll scan [path...] report what is in flight (also bare `toolroll` outside a repository)\n toolroll [path...] the same report for the paths given\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> add a project; it is in the console at once (no path: the repo you are in)\n toolroll repos remove <path>\n take it out of the console; its tasks and results stay\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll release <branch> release Toolroll itself: gate, your approval, deploy, merge, tag, publish\n (resumable: rerun to continue; --help)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll health recent server latency, write waits and live streams\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll lead token mint your lead agent's credential: its own work pings nobody\n toolroll lead say \"<text>\" (the lead) one short message in its person's chat\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / worker release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll flows list | show <id> | create | edit | trigger | script | card | archive\n set up and inspect flows (writes preview until --yes)\n toolroll chat --as <you> --token <t>\n talk to your lead: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll doctor check that Toolroll's service holds no key a build could read\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
package/dist/cli-help.js CHANGED
@@ -6,8 +6,12 @@ import { DEFAULT_MAX_DEPTH } from "./scan.js";
6
6
  export const HELP = `toolroll — a control plane for unattended coding agents
7
7
 
8
8
  Usage
9
- toolroll in a repository: start Toolroll for it, or open the one already running
10
- (under an agent or with --json: only says what it would do)
9
+ toolroll at your terminal: start Toolroll here (or attach) and open it in the terminal
10
+ (--web: the browser; --project-root <dir>: where projects live)
11
+ in a repository without a terminal, under an agent or with --json: only says what it would do
12
+ toolroll open [--web] open this computer's Toolroll again, signed in
13
+ toolroll welcome the three steps to a first result (--json: the model)
14
+ toolroll password set add a password (typed twice, at your terminal)
11
15
  toolroll scan [path...] report what is in flight (also bare \`toolroll\` outside a repository)
12
16
  toolroll [path...] the same report for the paths given
13
17
  toolroll pulls report what is waiting on a person
package/dist/cli.d.ts CHANGED
@@ -65,6 +65,12 @@ export type MainOptions = {
65
65
  release?: ReleaseCliDeps;
66
66
  /** Tests: bare `toolroll` in a repository (quick-start.ts) — its repository, agent check, proofs and actions. */
67
67
  quickStart?: Partial<QuickStartSeams>;
68
+ /** Tests: bare `toolroll` at a person's terminal (easy-start.ts) — the terminal and agent checks, and its seams. */
69
+ easyStart?: {
70
+ terminal?: boolean;
71
+ underAgent?: boolean;
72
+ seams?: Partial<import("./easy-start.js").EasyStartSeams>;
73
+ };
68
74
  };
69
75
  /**
70
76
  * Strip `-o <file>` / `--output <file>` before dispatch. The flag belongs to
package/dist/cli.js CHANGED
@@ -25,6 +25,8 @@ import { GUIDES, guideNamed } from "./guides.js";
25
25
  import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, isLargeRepo } from "./onboard.js";
26
26
  import { run as execRun } from "./exec.js";
27
27
  import { isBareInvocation, runQuickStart } from "./quick-start.js";
28
+ import { easyStartApplies, easyStartArgs } from "./easy-start-args.js";
29
+ import { underAgent } from "./prompt.js";
28
30
  import { COMMAND_GUIDE, SURFACE_NOTES, SURFACE_SCHEMA_VERSION } from "./surface.js";
29
31
  import { hasCentralProfile, maybeRunConnectCommand } from "./connect-cli.js";
30
32
  import { removedCommand, removedMessage } from "./deprecations.js";
@@ -158,6 +160,11 @@ export const OPERATE_COMMANDS = new Set([
158
160
  "serve",
159
161
  "watch",
160
162
  "daemon",
163
+ "background",
164
+ "stop",
165
+ "open",
166
+ "welcome",
167
+ "password",
161
168
  "bridge",
162
169
  "publish",
163
170
  "reconcile",
@@ -356,6 +363,22 @@ async function dispatch(argv, write, mainOptions) {
356
363
  if (first !== undefined && OPERATE_COMMANDS.has(first)) {
357
364
  return runOperate(first, rest, write, mainOptions.operate ?? {});
358
365
  }
366
+ // Easy start (A): bare `toolroll` (or with --web / --project-root) at a person's terminal — no --json, not under a
367
+ // coding agent — starts this computer's Toolroll and opens the terminal UI. Anything else keeps its behaviour below.
368
+ if (first !== "scan" && first !== "--json" && easyStartArgs(argv) !== null) {
369
+ const terminal = mainOptions.easyStart?.terminal ?? (process.stdin.isTTY === true && process.stdout.isTTY === true);
370
+ const agent = terminal && (mainOptions.easyStart?.underAgent ?? underAgent(process.env));
371
+ if (easyStartApplies(argv, { stdin: terminal, stdout: terminal, agent })) {
372
+ const databaseFile = mainOptions.operate?.databaseFile ?? databasePath(process.env, homedir());
373
+ const easy = await import("./easy-start.js");
374
+ const seams = easy.defaultEasyStartSeams({
375
+ databaseFile, write, build: (await import("./monitoring.js")).packageVersion(),
376
+ ...(mainOptions.operate === undefined ? {} : { operate: mainOptions.operate }),
377
+ admit: repo => runReposCommand(["add", repo], write, mainOptions.onboard),
378
+ });
379
+ return easy.runEasyStart("start", easyStartArgs(argv), { ...seams, ...mainOptions.easyStart?.seams });
380
+ }
381
+ }
359
382
  // Cockpit B1: the scan by name, and bare `toolroll` in a repository starting (or attaching to) its console.
360
383
  if (first === "scan")
361
384
  argv = rest;
package/dist/coding-ui.js CHANGED
@@ -142,6 +142,8 @@ export function codingWorkspaceScript() {
142
142
  const draftKey = () => 'standing-orders:coding-draft:' + root.dataset.codingOwner + ':' + (session || 'new:' + (composer.querySelector('[name="repo"]')?.value || root.dataset.codingProject));
143
143
  const storage = (operation, key, value) => { try { return sessionStorage[operation](key, value); } catch { return null; } };
144
144
  const requestSignal = () => typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(30000) : undefined;
145
+ // Easy-start C2: the browser mutation key, when the page's key fill offers one.
146
+ const keyed = headers => typeof window.soKeyHeaders === 'function' ? window.soKeyHeaders(headers) : headers;
145
147
  const saveDraft = () => {
146
148
  if (!prompt || !root.dataset.codingOwner) return;
147
149
  storage('setItem', draftKey(), JSON.stringify({text:prompt.value, requestId:requestField.value, pending:pendingSend, at:Date.now()}));
@@ -276,7 +278,7 @@ export function codingWorkspaceScript() {
276
278
  const area = root.querySelector('#coding-change-content');
277
279
  const button = root.querySelector('#coding-refresh-changes'); button.disabled = true;
278
280
  try {
279
- const response = await fetch(base + '/changes', {method:'POST',headers:{accept:'application/json'},body:new URLSearchParams({csrf}),credentials:'same-origin',cache:'no-store',signal:requestSignal()});
281
+ const response = await fetch(base + '/changes', {method:'POST',headers:keyed({accept:'application/json'}),body:new URLSearchParams({csrf}),credentials:'same-origin',cache:'no-store',signal:requestSignal()});
280
282
  if (response.status === 401 || response.redirected) { signedOut(); return; }
281
283
  if (!response.ok) throw new Error('Could not load changes. Try Refresh changes.');
282
284
  const changes = await response.json();
@@ -338,7 +340,7 @@ export function codingWorkspaceScript() {
338
340
  form.dataset.busy = 'true';
339
341
  const buttons = Array.from(form.querySelectorAll('button')); buttons.forEach(button => button.disabled = true);
340
342
  try {
341
- const response = await fetch(form.getAttribute('action'), {method:'POST',headers:{accept:'application/json','content-type':'application/x-www-form-urlencoded'},credentials:'same-origin',body:data.toString(),signal:requestSignal()});
343
+ const response = await fetch(form.getAttribute('action'), {method:'POST',headers:keyed({accept:'application/json','content-type':'application/x-www-form-urlencoded'}),credentials:'same-origin',body:data.toString(),signal:requestSignal()});
342
344
  if (response.status === 401 || response.redirected) { signedOut(); return; }
343
345
  const result = await response.json();
344
346
  if (!response.ok || !result.ok) {
@@ -6,6 +6,7 @@
6
6
  import { constants, closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { dirname, join } from 'node:path';
9
+ import { savedLoginFile } from './login-secrets.js';
9
10
  import { randomBytes } from 'node:crypto';
10
11
  import { envelopeJson } from './envelope.js';
11
12
  import { databasePath } from './store.js';
@@ -299,7 +300,7 @@ export async function maybeRunConnectCommand(argv, write, options = {}) {
299
300
  throw new UsageError('--login-file requires --local-login.');
300
301
  let account = value(flags, 'as');
301
302
  if (flags['local-login']) {
302
- const login = privateFile(value(flags, 'login-file') || join(dirname(databasePath(options.env ?? process.env, options.home ?? homedir())), 'up-login.txt'), 8192).trim().split(/\s+/);
303
+ const login = privateFile(value(flags, 'login-file') || savedLoginFile(dirname(databasePath(options.env ?? process.env, options.home ?? homedir()))), 8192).trim().split(/\s+/);
303
304
  if (login.length !== 2 || account && account !== login[0])
304
305
  throw new UsageError('The local sign-in does not match the requested account.');
305
306
  account = login[0];
@@ -35,6 +35,8 @@ export declare function readInstanceState(store: Store): {
35
35
  record: InstanceRecord;
36
36
  };
37
37
  export declare function readInstance(store: Store): InstanceRecord | null;
38
+ /** v147: the key epoch the running console recorded with its claim (easy-start C2), or null: none recorded. */
39
+ export declare function instanceKeyEpoch(store: Store): string | null;
38
40
  /** A stopping console forgets itself — only its own record, never a newer start's. */
39
41
  export declare function clearInstance(store: Store, secret: string): void;
40
42
  export declare const socketProof: (secret: string, challenge: string) => string;
@@ -70,8 +72,15 @@ export declare function askSocket(socketPath: string, request: object, timeoutMs
70
72
  * The console's discovery socket: one line in, one line out. `hello` (with a challenge) answers the public facts and
71
73
  * the socket proof; every other verb is refused. It reads nothing else and changes nothing. Bound under a fresh name
72
74
  * no other process has, so it never clears or replaces anything; its own listener removes it on close.
75
+ *
76
+ * Easy start D: the one other verb is `link`, and only when the console passes `link`. The console answers with a fresh
77
+ * challenge of its own; the asker must answer it with HMAC(secret, "link:" + challenge) — the secret is in the database
78
+ * (behind the agent fence), so only a process that read it can — and then gets one local owner's one-time sign-in path.
79
+ * Each challenge is spent by the first answer on its own connection, right or wrong; the "link:" domain keeps a hello or
80
+ * HTTP proof from ever counting here. The request names no account: what the link signs in as is the console's own
81
+ * decision (`link` returns null unless local mode is on).
73
82
  */
74
- export declare function openInstanceSocket(databaseFile: string, facts: HelloFacts, secret: () => string | null): Promise<{
83
+ export declare function openInstanceSocket(databaseFile: string, facts: HelloFacts, secret: () => string | null, link?: () => string | null): Promise<{
75
84
  ok: true;
76
85
  name: string;
77
86
  close: () => void;
@@ -79,6 +88,29 @@ export declare function openInstanceSocket(databaseFile: string, facts: HelloFac
79
88
  ok: false;
80
89
  problem: string;
81
90
  }>;
91
+ /** The `link` verb's answer to the console's challenge: bound to this verb, so no other proof ever counts for it. */
92
+ export declare const linkProof: (secret: string, challenge: string) => string;
93
+ /**
94
+ * Ask the console on `socketPath` for a local owner's one-time sign-in path with the record's secret: `link`, the
95
+ * console's challenge, then the proof. Null when it gives none (local mode off, a wrong or late answer, anything
96
+ * malformed). The caller proves the console live first (proveLiveConsole) and opens the path on its proved address.
97
+ */
98
+ export declare function askLink(socketPath: string, secret: string, timeoutMs?: number, connect?: (path: string) => Socket): Promise<string | null>;
99
+ /**
100
+ * A local owner's one-time sign-in address from this installation's live console (easy start D, `toolroll open`): the
101
+ * console is proved live on both channels first, then asked over its socket with the record's secret. The address is
102
+ * always the proved console's own; null when there is no proved console or it gives no link.
103
+ */
104
+ export declare function requestLocalLink(databaseFile: string, store: Store, build: string, seams?: AttachSeams & {
105
+ askLink?: typeof askLink;
106
+ }): Promise<{
107
+ ok: true;
108
+ url: string;
109
+ console: string;
110
+ } | {
111
+ ok: false;
112
+ message: string;
113
+ }>;
82
114
  /**
83
115
  * A record's console: "live" (its socket answers a fresh challenge with the record's secret and pid), "gone" (nobody
84
116
  * is listening on its socket, or the socket isn't there), or "unknown" (anything else: slow, malformed, a wrong proof).
@@ -95,6 +127,7 @@ export declare function claimInstance(store: Store, databaseFile: string, facts:
95
127
  port: number;
96
128
  build: string;
97
129
  socket: string;
130
+ keyEpoch?: string;
98
131
  }, now: Date, liveness?: (record: InstanceRecord) => Promise<"live" | "gone" | "unknown">): Promise<string | null>;
99
132
  export type AttachProof = {
100
133
  ok: true;
@@ -69,6 +69,11 @@ export function readInstance(store) {
69
69
  const read = readInstanceState(store);
70
70
  return read.state === "ok" ? read.record : null;
71
71
  }
72
+ /** v147: the key epoch the running console recorded with its claim (easy-start C2), or null: none recorded. */
73
+ export function instanceKeyEpoch(store) {
74
+ const row = store.handle.prepare("SELECT key_epoch FROM console_instance WHERE id = 1").get();
75
+ return row === undefined || row["key_epoch"] == null ? null : String(row["key_epoch"]);
76
+ }
72
77
  /** A stopping console forgets itself — only its own record, never a newer start's. */
73
78
  export function clearInstance(store, secret) {
74
79
  store.handle.prepare("DELETE FROM console_instance WHERE id = 1 AND secret = ?").run(secret);
@@ -189,8 +194,15 @@ export function askSocket(socketPath, request, timeoutMs = 2_000, connect = path
189
194
  * The console's discovery socket: one line in, one line out. `hello` (with a challenge) answers the public facts and
190
195
  * the socket proof; every other verb is refused. It reads nothing else and changes nothing. Bound under a fresh name
191
196
  * no other process has, so it never clears or replaces anything; its own listener removes it on close.
197
+ *
198
+ * Easy start D: the one other verb is `link`, and only when the console passes `link`. The console answers with a fresh
199
+ * challenge of its own; the asker must answer it with HMAC(secret, "link:" + challenge) — the secret is in the database
200
+ * (behind the agent fence), so only a process that read it can — and then gets one local owner's one-time sign-in path.
201
+ * Each challenge is spent by the first answer on its own connection, right or wrong; the "link:" domain keeps a hello or
202
+ * HTTP proof from ever counting here. The request names no account: what the link signs in as is the console's own
203
+ * decision (`link` returns null unless local mode is on).
192
204
  */
193
- export async function openInstanceSocket(databaseFile, facts, secret) {
205
+ export async function openInstanceSocket(databaseFile, facts, secret, link) {
194
206
  const dir = instanceRunDir(databaseFile);
195
207
  const problem = prepareRunDir(dir);
196
208
  if (problem !== null)
@@ -201,30 +213,60 @@ export async function openInstanceSocket(databaseFile, facts, secret) {
201
213
  return { ok: false, problem: pathProblem };
202
214
  const server = createServer(socket => {
203
215
  let buffer = "";
216
+ // The link challenge this connection was given, until its one answer arrives.
217
+ let challenged = null;
218
+ let done = false;
204
219
  socket.setEncoding("utf8");
205
220
  socket.setTimeout(5_000, () => socket.destroy());
206
- const reply = (body) => { socket.end(`${JSON.stringify(body)}\n`); };
207
- socket.on("error", () => socket.destroy());
208
- socket.on("data", chunk => {
209
- buffer += chunk;
210
- if (buffer.length > LINE_CAP)
211
- return void socket.destroy();
212
- const end = buffer.indexOf("\n");
213
- if (end < 0)
214
- return;
221
+ const reply = (body) => { done = true; socket.end(`${JSON.stringify(body)}\n`); };
222
+ const answerLine = (line) => {
215
223
  let asked;
216
224
  try {
217
- asked = JSON.parse(buffer.slice(0, end));
225
+ asked = JSON.parse(line);
218
226
  }
219
227
  catch {
220
228
  return reply({ error: "refused" });
221
229
  }
222
- const verb = asked !== null && typeof asked === "object" ? asked.verb : undefined;
223
- const challenge = asked !== null && typeof asked === "object" ? asked.challenge : undefined;
230
+ const field = (key) => asked !== null && typeof asked === "object" && !Array.isArray(asked) ? asked[key] : undefined;
224
231
  const key = secret();
232
+ if (challenged !== null) {
233
+ // The one answer to this connection's challenge: spent now, whatever it says.
234
+ const expected = key === null ? null : linkProof(key, challenged);
235
+ challenged = null;
236
+ if (expected === null || field("verb") !== "link-proof" || !same(field("proof"), expected))
237
+ return reply({ error: "refused" });
238
+ let path = null;
239
+ try {
240
+ path = link?.() ?? null;
241
+ }
242
+ catch {
243
+ path = null;
244
+ }
245
+ return reply(path === null ? { error: "no-link" } : { link: path });
246
+ }
247
+ const verb = field("verb");
248
+ if (verb === "link" && link !== undefined && key !== null) {
249
+ challenged = randomBytes(32).toString("hex");
250
+ socket.write(`${JSON.stringify({ challenge: challenged })}\n`);
251
+ return;
252
+ }
253
+ const challenge = field("challenge");
225
254
  if (verb !== "hello" || typeof challenge !== "string" || !CHALLENGE.test(challenge) || key === null)
226
255
  return reply({ error: "refused" });
227
256
  reply({ ...facts, proof: socketProof(key, challenge) });
257
+ };
258
+ socket.on("error", () => socket.destroy());
259
+ socket.on("data", chunk => {
260
+ if (done)
261
+ return;
262
+ buffer += chunk;
263
+ if (buffer.length > LINE_CAP)
264
+ return void socket.destroy();
265
+ for (let end = buffer.indexOf("\n"); end >= 0 && !done; end = buffer.indexOf("\n")) {
266
+ const line = buffer.slice(0, end);
267
+ buffer = buffer.slice(end + 1);
268
+ answerLine(line);
269
+ }
228
270
  });
229
271
  });
230
272
  // A Unix bind never replaces an existing path (EADDRINUSE).
@@ -233,6 +275,72 @@ export async function openInstanceSocket(databaseFile, facts, secret) {
233
275
  return { ok: false, problem: `couldn't listen on ${socketPath}` };
234
276
  return { ok: true, name, close: () => { server.close(); } };
235
277
  }
278
+ /** The `link` verb's answer to the console's challenge: bound to this verb, so no other proof ever counts for it. */
279
+ export const linkProof = (secret, challenge) => createHmac("sha256", secret).update(`link:${challenge}`).digest("hex");
280
+ const LINK_PATH = /^\/login\/once\/[A-Za-z0-9_-]{16,128}$/;
281
+ /**
282
+ * Ask the console on `socketPath` for a local owner's one-time sign-in path with the record's secret: `link`, the
283
+ * console's challenge, then the proof. Null when it gives none (local mode off, a wrong or late answer, anything
284
+ * malformed). The caller proves the console live first (proveLiveConsole) and opens the path on its proved address.
285
+ */
286
+ export function askLink(socketPath, secret, timeoutMs = 5_000, connect = path => createConnection(path)) {
287
+ return new Promise(resolve => {
288
+ let settled = false, buffer = "", sentProof = false;
289
+ const done = (value) => { if (settled)
290
+ return; settled = true; clearTimeout(timer); socket.destroy(); resolve(value); };
291
+ const socket = connect(socketPath);
292
+ const timer = setTimeout(() => done(null), timeoutMs);
293
+ socket.setEncoding("utf8");
294
+ socket.on("error", () => done(null));
295
+ socket.on("connect", () => socket.write(`${JSON.stringify({ verb: "link" })}\n`));
296
+ socket.on("data", chunk => {
297
+ buffer += chunk;
298
+ if (buffer.length > LINE_CAP)
299
+ return done(null);
300
+ for (let end = buffer.indexOf("\n"); end >= 0 && !settled; end = buffer.indexOf("\n")) {
301
+ const line = buffer.slice(0, end);
302
+ buffer = buffer.slice(end + 1);
303
+ let parsed;
304
+ try {
305
+ parsed = JSON.parse(line);
306
+ }
307
+ catch {
308
+ return done(null);
309
+ }
310
+ const body = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
311
+ if (!sentProof) {
312
+ const challenge = body["challenge"];
313
+ if (typeof challenge !== "string" || !/^[0-9a-f]{64}$/.test(challenge))
314
+ return done(null);
315
+ sentProof = true;
316
+ socket.write(`${JSON.stringify({ verb: "link-proof", proof: linkProof(secret, challenge) })}\n`);
317
+ }
318
+ else {
319
+ const path = body["link"];
320
+ return done(typeof path === "string" && LINK_PATH.test(path) ? path : null);
321
+ }
322
+ }
323
+ });
324
+ socket.on("end", () => done(null));
325
+ });
326
+ }
327
+ /**
328
+ * A local owner's one-time sign-in address from this installation's live console (easy start D, `toolroll open`): the
329
+ * console is proved live on both channels first, then asked over its socket with the record's secret. The address is
330
+ * always the proved console's own; null when there is no proved console or it gives no link.
331
+ */
332
+ export async function requestLocalLink(databaseFile, store, build, seams = {}) {
333
+ const proof = await proveLiveConsole(databaseFile, store, build, seams);
334
+ if (!proof.ok)
335
+ return { ok: false, message: proof.message };
336
+ const record = readInstance(store);
337
+ if (record === null || record.pid !== proof.pid || record.port !== proof.port)
338
+ return { ok: false, message: "the console changed while it was being checked" };
339
+ const path = await (seams.askLink ?? askLink)(join(instanceRunDir(databaseFile), record.socket), record.secret);
340
+ if (path === null)
341
+ return { ok: false, message: "the console gave no sign-in link (local mode is off, or it is still starting)" };
342
+ return { ok: true, url: new URL(path, proof.url).toString(), console: proof.url };
343
+ }
236
344
  /**
237
345
  * A record's console: "live" (its socket answers a fresh challenge with the record's secret and pid), "gone" (nobody
238
346
  * is listening on its socket, or the socket isn't there), or "unknown" (anything else: slow, malformed, a wrong proof).
@@ -268,8 +376,9 @@ export async function claimInstance(store, databaseFile, facts, now, liveness =
268
376
  // Exactly the record that was checked (or none): anything else is another start's newer claim.
269
377
  if (rawSecret() !== checked)
270
378
  return null;
271
- store.handle.prepare("INSERT INTO console_instance (id, secret, pid, port, build, socket, started_at) VALUES (1, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET secret = excluded.secret, pid = excluded.pid, port = excluded.port, build = excluded.build, socket = excluded.socket, started_at = excluded.started_at")
272
- .run(secret, facts.pid, facts.port, facts.build, facts.socket, now.toISOString());
379
+ // v147: the start's key epoch (easy-start C2) is recorded with its claim; a claim without one records none.
380
+ store.handle.prepare("INSERT INTO console_instance (id, secret, pid, port, build, socket, started_at, key_epoch) VALUES (1, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET secret = excluded.secret, pid = excluded.pid, port = excluded.port, build = excluded.build, socket = excluded.socket, started_at = excluded.started_at, key_epoch = excluded.key_epoch")
381
+ .run(secret, facts.pid, facts.port, facts.build, facts.socket, now.toISOString(), facts.keyEpoch ?? null);
273
382
  return secret;
274
383
  });
275
384
  }