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
@@ -119,6 +119,16 @@ export type ApprovalSheetInput = {
119
119
  } | null;
120
120
  /** Cockpit B2: this person's password is remembered (when, ISO) and these terms may stand on it. */
121
121
  freshSince?: string | null;
122
+ /** Easy-start C5: a laptop's local owner — eligible (no password field), or why not and where to go. */
123
+ localOwner?: LocalApprovalView | null;
124
+ };
125
+ export type LocalApprovalView = {
126
+ eligible: true;
127
+ } | {
128
+ eligible: false;
129
+ message: string;
130
+ href: string;
131
+ action: string;
122
132
  };
123
133
  /** The approval sheet: one form that restates every term it signs, the password, and Approve & start. */
124
134
  export declare function approvalSheetHtml(input: ApprovalSheetInput): Html;
@@ -548,6 +558,8 @@ export declare function taskBodyParts(data: {
548
558
  steerRecent?: number[];
549
559
  /** Cockpit B2: when this person's remembered password was given, when it may approve these terms. */
550
560
  approvalFresh?: string | null;
561
+ /** Easy-start C5: the viewer is a laptop's local owner — whether this plan is one it approves without a password. */
562
+ approvalLocal?: LocalApprovalView | null;
551
563
  /** Cockpit B2: how the current approval's person was proved (approval proved ledger), when it was a remembered one. */
552
564
  approvalProof?: {
553
565
  method: string;
@@ -239,9 +239,11 @@ export function approvalSheetHtml(input) {
239
239
  parts.lead,
240
240
  parts.rows,
241
241
  html `<div class="approval-act" id="${input.surface === "task" ? "approval-confirm" : "chat-approval-confirm"}">`,
242
- html `${reauth(html `<label class="approval-password"><span class="visually-hidden">Your password</span><input type="password" name="token" autocomplete="current-password" placeholder="Your password" aria-describedby="${parts.passwordNote}"></label>`)}`,
243
- input.freshSince == null ? html `<p class="approval-password-note" id="${parts.passwordNote}">Your password signs this approval.</p>`
244
- : html `<p class="approval-password-note" id="${parts.passwordNote}" data-approval-fresh>Your password from ${whenTime(input.freshSince)} is remembered on this computer — leave this blank to approve with it, or type it again.</p>`,
242
+ input.localOwner != null ? html `` : html `${reauth(html `<label class="approval-password"><span class="visually-hidden">Your password</span><input type="password" name="token" autocomplete="current-password" placeholder="Your password" aria-describedby="${parts.passwordNote}"></label>`)}`,
243
+ input.localOwner?.eligible === true ? html ``
244
+ : input.localOwner != null ? html `<p class="approval-password-note" id="${parts.passwordNote}">${input.localOwner.message} <a href="${input.localOwner.href}">${input.localOwner.action}</a></p>`
245
+ : input.freshSince == null ? html `<p class="approval-password-note" id="${parts.passwordNote}">Your password signs this approval.</p>`
246
+ : html `<p class="approval-password-note" id="${parts.passwordNote}" data-approval-fresh>Your password from ${whenTime(input.freshSince)} is remembered on this computer — leave this blank to approve with it, or type it again.</p>`,
245
247
  html `<button type="submit" data-primary-action>${parts.submit}</button></div>`,
246
248
  html `<p class="approval-after">${parts.after}</p>`,
247
249
  html `<div class="approval-secondary">${parts.editor ?? html `<a class="approval-link" href="${input.editHref}">Edit plan</a>`}<a class="approval-link" href="${input.notNowHref}">Not now</a></div>`,
@@ -782,6 +784,9 @@ export function approvalProofWords(proof) {
782
784
  return " (from your paired chat)";
783
785
  if (proof.method === "carried")
784
786
  return " (carried from the release approval)";
787
+ // Easy-start C5: this computer's owner approving attended work in local mode, with no password.
788
+ if (proof.method === "local-owner")
789
+ return " (on this computer, no password)";
785
790
  return "";
786
791
  }
787
792
  const whenText = (iso) => new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }).format(new Date(iso));
@@ -1083,8 +1088,10 @@ export function decisionAnswerScript() {
1083
1088
  `var f=e.target;if(!f||!f.classList||!f.classList.contains("decide-inline"))return;` +
1084
1089
  `e.preventDefault();` +
1085
1090
  `var card=f.closest("[data-decision-id]");` +
1086
- `var page=f.action.replace(/\\/answer$/,"");` +
1087
- `fetch(f.action,{method:"POST",credentials:"same-origin",` +
1091
+ // The action attribute, not f.action: a field named "action" would shadow that property (DOM clobbering).
1092
+ `var to=new URL(HTMLFormElement.prototype.getAttribute.call(f,"action")||"",location.href).href;` +
1093
+ `var page=to.replace(/\\/answer$/,"");` +
1094
+ `fetch(to,{method:"POST",credentials:"same-origin",` +
1088
1095
  `headers:{"content-type":"application/x-www-form-urlencoded"},` +
1089
1096
  `body:new URLSearchParams(new FormData(f)).toString()})` +
1090
1097
  `.then(function(r){return r.text().then(function(t){return{r:r,t:t}})})` +
@@ -1651,6 +1658,7 @@ export function taskBodyParts(data) {
1651
1658
  nonce: data.nonce,
1652
1659
  digest: data.approvalDigest ?? scope.digest,
1653
1660
  freshSince: data.approvalFresh ?? null,
1661
+ localOwner: data.approvalLocal ?? null,
1654
1662
  returnTo: null,
1655
1663
  scope,
1656
1664
  planDocument: data.plan === "drafted" ? data.planDocument : null,
@@ -2552,8 +2560,10 @@ export function approveKitOf(input, freshSince) {
2552
2560
  heading: rebuild !== null ? "The plan changed" : input.deliverable === "report" ? "Investigation ready" : "Plan ready",
2553
2561
  text: `Approving signs plan version ${input.scope.digest.slice(0, 12)} and ${after}.${allowing}`,
2554
2562
  kind: "standard",
2555
- facts: { passwordless: passwordlessRendering(), remembered: until !== null },
2563
+ facts: { passwordless: passwordlessRendering() || input.localOwner?.eligible === true, remembered: until !== null },
2556
2564
  rememberedUntil: until,
2565
+ // Easy-start C5: the owner approving on this computer needs no password, and the line says so (not "Passwordless").
2566
+ ...(input.localOwner?.eligible === true ? { termsWords: "No password needed on this computer." } : {}),
2557
2567
  planHtml: htmlString(joinHtml([parts.lead, parts.rows, parts.editor ?? "", parts.details, parts.editorForm])),
2558
2568
  };
2559
2569
  }
@@ -45,6 +45,12 @@ export type RequestFacts = {
45
45
  passwordless?: boolean;
46
46
  /** The browser session's open project (display only: the sidebar's picker names it). */
47
47
  openProject?: string | null;
48
+ /** Easy start: a local owner with no password yet: every step-up field is the Set a password link instead (this href). */
49
+ localStepUp?: string;
50
+ /** Easy-start C2: the browser mutation key is required here, so every page that posts carries the key fill script. */
51
+ keyFill?: boolean;
52
+ /** Easy-start C2: this session's key is from an earlier console start (or it has none): how to reconnect. */
53
+ reconnect?: import("./mutation-key.js").Reconnect | null;
48
54
  };
49
55
  /**
50
56
  * The request's own session facts, readable from anywhere below the
@@ -16,6 +16,7 @@
16
16
  * admission: passwords, nonces, webhook signatures, object membership, stale
17
17
  * sessions and revoked tokens.
18
18
  */
19
+ import type { LocalOwnerAct } from "../local-owner.js";
19
20
  /** Who presents the request. `service` is a verified external sender (a signed webhook, an OAuth return carrying its one-time state). */
20
21
  export type Caller = "anonymous" | "service" | "cookie" | "bearer" | "oauth" | "coordinator" | "lead";
21
22
  /** What the caller must hold: nothing, read, act (an approver or an `act` token), or a fresh password/provider check on top. */
@@ -86,7 +87,22 @@ export interface RouteDeclaration {
86
87
  /** Cockpit B2: whether a password remembered on this session may stand in here (`allowlist`), or never. Only the
87
88
  * allowlisted acts run their check inside `withFreshness` (scope.ts); every other step-up is `never`. */
88
89
  readonly freshness: "never" | "allowlist";
90
+ /**
91
+ * Easy-start C1: whether a local owner's session (a laptop install's passwordless owner) may use this row, decided for
92
+ * every row when the table loads (localMarkOf below). `allow` rows name the act they are (local-owner.ts
93
+ * LOCAL_OWNER_ACTS); the console's admission refuses a local owner everywhere else before the handler runs.
94
+ */
95
+ readonly local: "allow" | "deny";
96
+ readonly localAct: LocalOwnerAct | null;
89
97
  }
98
+ /** Every edge row, decided: allow where a local owner's cookie may reach it, deny where its adapter refuses one (or no
99
+ * cookie is ever read there). A new edge row fails the table's load until it is listed here. */
100
+ export declare const EDGE_LOCAL: Readonly<Record<string, {
101
+ local: "allow" | "deny";
102
+ why: string;
103
+ }>>;
104
+ /** The ids the local marks name, for the inventory test (a mark naming no row is stale). */
105
+ export declare const LOCAL_MARKED_IDS: readonly string[];
90
106
  /** The sole route table, in match order. */
91
107
  export declare const ROUTES: readonly RouteDeclaration[];
92
108
  /** The first row that declares this method and path, or null: an undeclared request never reaches a handler. */
@@ -45,8 +45,114 @@ function row(stage, domain, id, method, pattern, options = {}) {
45
45
  ...(options.roleRefusal === undefined ? {} : { roleRefusal: options.roleRefusal }),
46
46
  ...(options.bodyCap === undefined ? {} : { bodyCap: options.bodyCap }),
47
47
  freshness: options.freshness ?? "never",
48
+ ...localMarkOf(stage, method, id),
48
49
  });
49
50
  }
51
+ // ---- easy-start C1: the local owner's marks --------------------------------------------------------------------------
52
+ // Decided here for every row, failing the table's load for an edge row nobody decided. A console POST is refused to a
53
+ // local owner unless LOCAL_POSTS names its act; a console GET is a read (`read`) unless LOCAL_GET_DENY names it.
54
+ /** The console POST rows a local owner may use, and the act each is. Everything else is refused. */
55
+ const LOCAL_POSTS = Object.freeze({
56
+ "tasks.add": "file-task",
57
+ "chat.file": "file-task",
58
+ "chat.send": "lead-send",
59
+ "chat.queued-remove": "lead-send",
60
+ "chat.mate-mint": "lead-send",
61
+ "chat.mate-follow": "lead-send",
62
+ "chat.mate-end": "lead-send",
63
+ "chat.mate-stop": "lead-send",
64
+ // The confirm door narrows the act to the card's own (lead-doors.ts: LOCAL_OWNER_PROPOSALS and the operation's act).
65
+ "chat.proposal.confirm": "lead-send",
66
+ "chat.proposal.dismiss": "lead-send",
67
+ "task.act.steer": "lead-send",
68
+ "task.act.approve": "approve-plan",
69
+ "decision.answer": "answer-question",
70
+ "task.act.reply": "answer-question",
71
+ "task.act.stop": "stop-run",
72
+ "task.act.cancel": "stop-run",
73
+ "task.act.hold": "pause-run",
74
+ "task.act.pause": "pause-run",
75
+ "task.act.unhold": "resume-run",
76
+ "task.act.resume-arm": "resume-run",
77
+ "task.act.resume": "resume-run",
78
+ "task.act.complete": "complete-task",
79
+ "run.act.revise": "request-revision",
80
+ "run.act.note": "request-revision",
81
+ "run.act.comment": "request-revision",
82
+ "task.act.archive": "archive-task",
83
+ "task.act.restore": "archive-task",
84
+ "projects.open": "add-project",
85
+ "projects.github-list": "add-project",
86
+ "projects.onboard-preview": "add-project",
87
+ "projects.onboard-confirm": "add-project",
88
+ // The welcome's sample project, added the way Projects adds a folder (the same admission and its re-check).
89
+ "welcome.sample": "add-project",
90
+ // The welcome's "Use this one" (and Settings → Lead's turn-on): the lead runs on Claude Code or Codex signed in here.
91
+ "settings.lead-on": "choose-default-agent",
92
+ // Choosing which open project the pages show: a view filter, never authorization.
93
+ "projects.select": "read",
94
+ "agents.seen": "mark-seen",
95
+ "agents.watch": "watch-agent",
96
+ });
97
+ /** Console GET rows refused to a local owner (none so far: every GET is a read; adapter-inventory.test.ts proves no GET
98
+ * writes beyond the allowlisted session touch and review nonce). */
99
+ const LOCAL_GET_DENY = new Set([]);
100
+ /** Every edge row, decided: allow where a local owner's cookie may reach it, deny where its adapter refuses one (or no
101
+ * cookie is ever read there). A new edge row fails the table's load until it is listed here. */
102
+ export const EDGE_LOCAL = Object.freeze({
103
+ "edge.healthz": { local: "allow", why: "public read" },
104
+ "edge.instance": { local: "allow", why: "public read (the CLI proves the console)" },
105
+ "edge.telegram-hook": { local: "deny", why: "signed webhook; never a cookie" },
106
+ "edge.flow-form": { local: "deny", why: "a flow's public form; never a cookie" },
107
+ "edge.flow-form-send": { local: "deny", why: "a flow's public form; never a cookie" },
108
+ "edge.flow-hook": { local: "deny", why: "signed webhook; never a cookie" },
109
+ "edge.mcp": { local: "deny", why: "bearer only" },
110
+ "edge.oauth-discovery": { local: "allow", why: "public read" },
111
+ "edge.oauth-register": { local: "deny", why: "no cookie; registers a client only" },
112
+ "edge.oauth-token": { local: "deny", why: "OAuth client only" },
113
+ "edge.oauth-authorize": { local: "deny", why: "consent is a credential act: the adapter refuses a local owner's session" },
114
+ "edge.oauth-consent": { local: "deny", why: "consent is a credential act: the adapter refuses a local owner's session" },
115
+ "edge.google-callback": { local: "deny", why: "bound to state a refused settings POST starts" },
116
+ "edge.connect-callback": { local: "deny", why: "bound to state a refused settings POST starts" },
117
+ "edge.team-read": { local: "deny", why: "team conversations are a remote feature: authenticate refuses a local owner" },
118
+ "edge.team-send": { local: "deny", why: "team conversations are a remote feature: authenticate refuses a local owner" },
119
+ "edge.cli": { local: "deny", why: "bearer only" },
120
+ "edge.teams": { local: "deny", why: "signed service only" },
121
+ "edge.browser-asset": { local: "allow", why: "public asset" },
122
+ "edge.workspace-style": { local: "allow", why: "public asset" },
123
+ "edge.install-asset": { local: "allow", why: "public asset" },
124
+ "edge.font": { local: "allow", why: "public asset" },
125
+ "edge.desktop-health": { local: "allow", why: "public read" },
126
+ "edge.login-page": { local: "allow", why: "before sign-in" },
127
+ "edge.login-link": { local: "allow", why: "before sign-in: a one-time link" },
128
+ "edge.sso-start": { local: "allow", why: "before sign-in" },
129
+ "edge.sso-callback": { local: "allow", why: "before sign-in" },
130
+ "edge.sso-finish": { local: "allow", why: "before sign-in" },
131
+ "edge.signup": { local: "allow", why: "before sign-in" },
132
+ "edge.login": { local: "allow", why: "before sign-in: a password typed now makes a full session" },
133
+ "edge.logout": { local: "allow", why: "only narrows: ends this session" },
134
+ "edge.join": { local: "allow", why: "before sign-in: an invitation" },
135
+ "edge.join-send": { local: "allow", why: "before sign-in: an invitation" },
136
+ "edge.rekey-page": { local: "allow", why: "reads only: a local owner is told to run `toolroll open`" },
137
+ "edge.rekey": { local: "allow", why: "only a password typed now re-keys a full session; a local owner is told to run `toolroll open` and gets no key" },
138
+ "edge.password-set-page": { local: "allow", why: "reads only: the local owner's form for a first password, or \"already set — sign in\"" },
139
+ "edge.password-set": { local: "allow", why: "the one credential act a local owner may make: its first password (Origin, CSRF and key proved by the handler; enrollOwnerPassword re-checks local mode, the owner and password_set=0 in its own transaction); a session with a password set is refused" },
140
+ live: { local: "allow", why: "observational only; the agent watch lease is a keyed POST (agents.watch)" },
141
+ });
142
+ function localMarkOf(stage, method, id) {
143
+ if (stage === "edge") {
144
+ const decided = Object.prototype.hasOwnProperty.call(EDGE_LOCAL, id) ? EDGE_LOCAL[id] : undefined;
145
+ if (decided === undefined)
146
+ throw new Error(`route table: edge row ${id} has no local-owner mark (EDGE_LOCAL)`);
147
+ return { local: decided.local, localAct: decided.local === "allow" ? "read" : null };
148
+ }
149
+ if (method !== "POST")
150
+ return LOCAL_GET_DENY.has(id) ? { local: "deny", localAct: null } : { local: "allow", localAct: "read" };
151
+ const act = Object.prototype.hasOwnProperty.call(LOCAL_POSTS, id) ? LOCAL_POSTS[id] : null;
152
+ return act === null ? { local: "deny", localAct: null } : { local: "allow", localAct: act };
153
+ }
154
+ /** The ids the local marks name, for the inventory test (a mark naming no row is stale). */
155
+ export const LOCAL_MARKED_IDS = Object.freeze([...Object.keys(LOCAL_POSTS), ...LOCAL_GET_DENY, ...Object.keys(EDGE_LOCAL)]);
50
156
  const edge = (domain, id, method, path, options = {}) => row("edge", domain, id, method, path.startsWith("^") ? path : exact(path), options);
51
157
  const get = (domain, id, path, options = {}) => row("console", domain, id, "GET", path.startsWith("^") ? path : exact(path), options);
52
158
  const post = (domain, id, path, options = {}) => row("console", domain, id, "POST", path.startsWith("^") ? path : exact(path), options);
@@ -100,6 +206,12 @@ edge("people", "edge.sso-finish", "GET", "/login/sso/finish");
100
206
  edge("people", "edge.signup", "POST", "/signup");
101
207
  edge("people", "edge.login", "POST", "/login");
102
208
  edge("people", "edge.logout", "POST", "/logout", { callers: ["anonymous", "cookie"] });
209
+ // Easy-start C2: a browser session's fresh mutation key after a restart: its own password again (the handler proves both).
210
+ edge("people", "edge.rekey-page", "GET", "/login/rekey", { callers: ["anonymous", "cookie"] });
211
+ edge("people", "edge.rekey", "POST", "/login/rekey", { callers: ["anonymous", "cookie"] });
212
+ // Easy-start C4: the local owner sets a first password (only while none is set); the answer is a fresh full session.
213
+ edge("people", "edge.password-set-page", "GET", "/password/set", { callers: ["anonymous", "cookie"] });
214
+ edge("people", "edge.password-set", "POST", "/password/set", { callers: ["anonymous", "cookie"] });
103
215
  edge("people", "edge.join", "GET", "^\\/join\\/[A-Za-z0-9_-]{16,64}$", { sample: "/join/abcdefghijklmnop" });
104
216
  edge("people", "edge.join-send", "POST", "^\\/join\\/[A-Za-z0-9_-]{16,64}$", { sample: "/join/abcdefghijklmnop" });
105
217
  // ---- console reads ---------------------------------------------------------------------------------------------
@@ -165,6 +277,8 @@ get("pages", "fleet", "/fleet", free);
165
277
  get("tasks", "tasks.new", "/tasks/new", { limited: "collection", ...free });
166
278
  // The first run's own page (Wave E): five guided steps, reads only; it leads Home once the first run is over.
167
279
  get("tasks", "welcome", "/welcome", { limited: "collection", ...free });
280
+ // Try a sample project (first-run.ts, sample-project.ts): made in the project folder, then added the way Projects adds a folder.
281
+ post("tasks", "welcome.sample", "/welcome/sample", { callers: ["cookie"], project: "none", ...approver("An approver adds projects.", "/welcome") });
168
282
  get("tasks", "task.page", `^\\/t\\/${TASK}$`, { project: "task", limited: "resource", ...free, sample: "/t/one" });
169
283
  get("tasks", "task.evidence", `^\\/t\\/${TASK}\\/evidence$`, { project: "task", limited: "resource", ...free, sample: "/t/one/evidence" });
170
284
  get("pages", "ledger.export", "/ledger/export", { limited: "unscoped", ...free });
@@ -329,6 +443,8 @@ post("tasks", "sweep.cancel-rest", "^\\/sweeps\\/[1-9][0-9]{0,9}\\/cancel-rest$"
329
443
  // (the handler proves it as a credential act); turning it off is one click, so the row itself is not a step-up.
330
444
  get("settings", "settings.passwordless", "/settings/passwordless", { ...free, ...operator("An instance operator sets Passwordless.", "/settings") });
331
445
  post("settings", "settings.passwordless-send", "/settings/passwordless", { callers: ["cookie"], ...operator("An instance operator sets Passwordless.", "/settings") });
446
+ // Easy-start C5: turning local mode off: an instance operator's step-up from a full session (a local owner is refused).
447
+ post("settings", "settings.local-mode-off", "/settings/local-mode/off", { callers: ["cookie"], scope: "step-up", ...operator("An instance operator turns off local mode.", "/settings/access") });
332
448
  // Release approval (docs/plans/auto-approve-green.md): an approver of the project, from a browser. Approving releases
333
449
  // automatically takes a typed password (the handler proves it as a credential act); asking again is one click.
334
450
  post("settings", "settings.release-approval-send", "/settings/release-approval", { callers: ["cookie"], ...approver("An approver sets how releases are approved.", "/settings/approval") });
@@ -473,6 +589,11 @@ for (const one of ["note", "comment", "revise", "draft-repair", "checks", "add-t
473
589
  // Cockpit E: reveal a running attempt's proved checkout in Finder (nothing is launched in it).
474
590
  post("tasks", "run.act.reveal", `^\\/r\\/${ID}\\/reveal$`, { project: "run", limited: "resource", callers: ["cookie"], sample: "/r/1/reveal", ...approver("Only an approver can show an agent's files.") });
475
591
  post("tasks", "session.editor-links", "/session/editor-links", { viewer: true });
592
+ // Easy-start C1 (N1): reads never write. The shell says a finished result was opened (it leaves the person's unread
593
+ // Done), and holds or releases the short lease that quiets chat pings while an agent's page is open — keyed POSTs, never
594
+ // a page load or the live stream. A watch-only account keeps its own seen marks and watches too.
595
+ post("tasks", "agents.seen", "/agents/seen", { callers: ["cookie"], project: "none", limited: "unscoped", viewer: true });
596
+ post("tasks", "agents.watch", "/agents/watch", { callers: ["cookie"], project: "none", limited: "unscoped", viewer: true });
476
597
  post("tasks", "incident.resolve", `^\\/i\\/${ID}\\/resolve$`, { project: "incident", sample: "/i/1/resolve" });
477
598
  /** The sole route table, in match order. */
478
599
  export const ROUTES = Object.freeze(rows.map(one => Object.freeze(one)));
@@ -210,6 +210,7 @@ export interface ServerRuntime {
210
210
  authorizeMutation: (request: IncomingMessage, who: Who, body: FormView<FormFieldOf<"mutationGuard">>) => {
211
211
  status: number;
212
212
  message: string;
213
+ reconnect?: import("./mutation-key.js").Reconnect;
213
214
  } | null;
214
215
  leadSaid: Map<string, {
215
216
  turn: number | null;
@@ -245,10 +246,11 @@ export interface ServerRuntime {
245
246
  label: string;
246
247
  operatorsOnly: boolean;
247
248
  } | null;
248
- signInLinks: Map<string, {
249
- account: string;
250
- expires: number;
251
- }>;
249
+ signInLinks: Map<string, import("./session.js").SignInLink>;
250
+ /** v147: this console start's key epoch (easy-start C2). */
251
+ keyEpoch: string;
252
+ /** Easy-start C2: the browser mutation key (server/mutation-key.ts): the one check, issuing and delivering a key. */
253
+ browserKeys: import("./mutation-key.js").BrowserKeys;
252
254
  linkKey: (code: string) => string;
253
255
  fromThisComputer: (request: IncomingMessage) => boolean;
254
256
  /** Cockpit B2: the request's live password-freshness facts (null off a browser session). */
@@ -2,6 +2,7 @@
2
2
  import { type ChatDraft } from "../converse.js";
3
3
  import { type Principal } from "../operate-remote.js";
4
4
  import { type Store } from "../store.js";
5
+ import { type Authority } from "../local-owner.js";
5
6
  import { type Server } from "node:http";
6
7
  /** A user agent, reduced to safe display words — never echoed raw. */
7
8
  export declare function oneLineUa(raw: string | string[] | undefined): string;
@@ -27,6 +28,14 @@ export declare const NO_TOUCH_FRAGMENTS: ReadonlySet<string>;
27
28
  export type Session = {
28
29
  name: string;
29
30
  csrf: string;
31
+ /** v147 (easy-start C1): full, or the local owner's restricted authority. Set when the session is made; never changed. */
32
+ authority: Authority;
33
+ /** v147: a local owner's session stands only while local mode is on at this generation (null for a full session). */
34
+ localGeneration: number | null;
35
+ /** v147 (C2, filled in by the browser-key wave): the console start whose mutation key this session holds, and a hash of
36
+ * that key (never the key). Null: no key yet, or a session saved before v147 — stale, so mutations ask to reconnect. */
37
+ keyEpoch: string | null;
38
+ keyHash: string | null;
30
39
  /** v29: the account's standing at login — the central gate reads it;
31
40
  * the revocation cascade kills the session outright. */
32
41
  role: "approver" | "viewer";
@@ -108,7 +117,8 @@ export type Who = {
108
117
  name: string;
109
118
  via: "cookie";
110
119
  session: Session;
111
- role: "approver" | "viewer";
120
+ role: "approver" | "viewer"; /** v147: the session's own authority. */
121
+ authority: Authority;
112
122
  } | {
113
123
  name: string;
114
124
  via: "bearer";
@@ -117,6 +127,11 @@ export type Who = {
117
127
  /** The complete proved API-token authority, retained across body reads. */ principal?: Principal;
118
128
  generation: number;
119
129
  };
130
+ /** A full session's authority fields when it is made (a password, an identity-provider sign-in, a sign-up or join).
131
+ * The browser-key wave adds the key epoch and hash here. */
132
+ export declare const FULL_SESSION: Pick<Session, "authority" | "localGeneration" | "keyEpoch" | "keyHash">;
133
+ /** A caller's authority: a browser session's own, or full for a bearer credential (an API token or a password). */
134
+ export declare function authorityOfWho(who: Who): Authority;
120
135
  /** The browser session of a caller its route admits only through a browser sign-in (the row's callers or role). */
121
136
  export declare function browserCaller(who: Who): Extract<Who, {
122
137
  via: "cookie";
@@ -157,6 +172,21 @@ export declare class PersistentSessions extends Map<string, Session> {
157
172
  * closeCoding starts the coding shutdown on its own, ahead of the rest of a stop; close() awaits the same promise. */
158
173
  export type DecisionServer = Server & {
159
174
  mintSignInLink(account: string): string | null;
175
+ mintLocalLink(): string | null;
176
+ readonly keyEpoch: string;
160
177
  closeCoding(): Promise<void>;
161
178
  };
179
+ /** A one-time sign-in link as the console holds it (only a hash of its code is the key): a full one for an account,
180
+ * or a local owner's (easy-start C4), bound to local mode's generation and the owner's account generation when minted. */
181
+ export type SignInLink = {
182
+ kind: "full";
183
+ account: string;
184
+ expires: number;
185
+ } | {
186
+ kind: "local-owner";
187
+ account: string;
188
+ localGeneration: number;
189
+ accountGeneration: number;
190
+ expires: number;
191
+ };
162
192
  export type SsoIntent = "sign-in" | "reauth" | "link";
@@ -2,6 +2,7 @@
2
2
  import {} from "../converse.js";
3
3
  import {} from "../operate-remote.js";
4
4
  import {} from "../store.js";
5
+ import {} from "../local-owner.js";
5
6
  import { createHash } from "node:crypto";
6
7
  import {} from "node:http";
7
8
  /** A user agent, reduced to safe display words — never echoed raw. */
@@ -33,6 +34,13 @@ export const NONCE_TTL_MS = 15 * 60_000;
33
34
  export const NONCE_CAP = 500;
34
35
  /** Read-only fragment polls that must never refresh session activity (arc 1). */
35
36
  export const NO_TOUCH_FRAGMENTS = new Set(["1", "facts", "peek", "rail", "transcript"]);
37
+ /** A full session's authority fields when it is made (a password, an identity-provider sign-in, a sign-up or join).
38
+ * The browser-key wave adds the key epoch and hash here. */
39
+ export const FULL_SESSION = Object.freeze({ authority: "full", localGeneration: null, keyEpoch: null, keyHash: null });
40
+ /** A caller's authority: a browser session's own, or full for a bearer credential (an API token or a password). */
41
+ export function authorityOfWho(who) {
42
+ return who.via === "cookie" ? who.authority : "full";
43
+ }
36
44
  /** The browser session of a caller its route admits only through a browser sign-in (the row's callers or role). */
37
45
  export function browserCaller(who) {
38
46
  if (who.via !== "cookie")
@@ -59,7 +67,8 @@ export class PersistentSessions extends Map {
59
67
  this.ids.set(session, id);
60
68
  this.savedAt.set(session, Date.now());
61
69
  this.db.saveWebSession({ idHash: PersistentSessions.hash(id), account: session.name, csrf: session.csrf, role: session.role, generation: session.generation, createdAt: session.createdAt,
62
- lastSeen: session.lastSeen, project: session.project, projectRevision: session.projectRevision, ssoAt: session.sso?.at ?? null, agent: session.agent ?? null, address: session.address ?? null });
70
+ lastSeen: session.lastSeen, project: session.project, projectRevision: session.projectRevision, ssoAt: session.sso?.at ?? null, agent: session.agent ?? null, address: session.address ?? null,
71
+ authority: session.authority, localGeneration: session.localGeneration, keyEpoch: session.keyEpoch, keyHash: session.keyHash });
63
72
  return this;
64
73
  }
65
74
  delete(id) {
@@ -89,7 +98,8 @@ export class PersistentSessions extends Map {
89
98
  if (row === null)
90
99
  return null;
91
100
  const session = { name: row.account, csrf: row.csrf, role: row.role, generation: row.generation, createdAt: row.createdAt, lastSeen: row.lastSeen, sawBoardAt: null,
92
- project: row.project, projectRevision: row.projectRevision, agent: row.agent, address: row.address, ...(row.ssoAt === null ? {} : { sso: { at: row.ssoAt } }) };
101
+ project: row.project, projectRevision: row.projectRevision, agent: row.agent, address: row.address, ...(row.ssoAt === null ? {} : { sso: { at: row.ssoAt } }),
102
+ authority: row.authority, localGeneration: row.localGeneration, keyEpoch: row.keyEpoch, keyHash: row.keyHash };
93
103
  super.set(id, session);
94
104
  this.ids.set(session, id);
95
105
  this.savedAt.set(session, Date.now());
@@ -76,7 +76,7 @@ import { startConnect } from "../mcp-connect.js";
76
76
  import { decideProposal, listProposals, memoryStatus } from "../memory-pass.js";
77
77
  import { checkModels, isNewModel, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "../model-catalog.js";
78
78
  import { modelsHtml, modelsScript } from "../models-ui.js";
79
- import { ALL_SLOTS, applyModelChoices, browserWords, checkScope, DEFAULT_PRESET, isPresetName, newerModels, PRESETS, presetChange, presetInUse, previewModelChange, recentRuns, rowAt, slotKey, SLOTS, slotWords, stateDigest, switchChange } from "../model-choices.js";
79
+ import { ALL_SLOTS, applyModelChoices, browserWords, checkScope, DEFAULT_PRESET, defaultAgentOf, fillDefaultRouting, isPresetName, newerModels, PRESETS, presetChange, presetInUse, previewModelChange, recentRuns, rowAt, slotKey, SLOTS, slotWords, stateDigest, switchChange } from "../model-choices.js";
80
80
  import { HEADROOM_FIVE_HOUR_LIMIT, HEADROOM_WEEKLY_LIMIT, exactModelId } from "../phase-routing.js";
81
81
  import { RISKY_MEANS, SIZE_MEANS, SIZING_CLAUDE_MODEL } from "../task-sizing.js";
82
82
  import { CHAT_APPROVE_ALL, MODE_MAX_DAYS, modeDigestOf, modeTermsJson, modeWords, presetTerms } from "../modes.js";
@@ -89,6 +89,7 @@ import { parseProjectConcurrency, saveProjectConcurrency } from "../project-conc
89
89
  import { MAX_NETWORK_HOSTS, normalizeHost } from "../egress.js";
90
90
  import { isWritesMode } from "../writes-mode.js";
91
91
  import { isCheckAccess, signInsUnavailableWords, widerThan } from "../check-access.js";
92
+ import { LOCAL_MODE_CONFINES, localModeOn } from "../local-owner.js";
92
93
  import { parseMachLookup, parseReadDeny } from "../project-sandbox.js";
93
94
  import { projectDeleteConfirmHtml, projectSettingsHtml } from "../project-delete-ui.js";
94
95
  import { deleteProject, holdingsWords } from "../project-delete.js";
@@ -126,7 +127,7 @@ import { sweepSchedule } from "../storage-sweep-schedule.js";
126
127
  import { storageHtml } from "../storage-ui.js";
127
128
  import { landedSummary } from "../landed-cleanup.js";
128
129
  import { bytesWords, parseCleanup } from "../storage.js";
129
- import { isDigestTime, liveBackupState, RESULT_SCREENSHOTS, setLiveBackupSettings } from "../store.js";
130
+ import { isDigestTime, liveBackupState, localModeOf, RESULT_SCREENSHOTS, setLiveBackupSettings, setLocalModeOff } from "../store.js";
130
131
  import { defaultLiveFolder, liveFolderOf, liveFolderProblem } from "../live-backup.js";
131
132
  import { LIVE_EVERY } from "../backup-ui.js";
132
133
  import { checkTeamsCredentials, clearTeamsCredentials, loadTeamsCredentials, saveTeamsCredentials, TeamsError } from "../teams-api.js";
@@ -775,6 +776,12 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
775
776
  return null;
776
777
  } })() : null,
777
778
  access: { sessions: store.webSessions(who.name).length, tokens: store.apiTokens(who.name).filter(one => one.revokedAt === null && Date.parse(one.expiresAt) > now.getTime()).length },
779
+ local: (() => {
780
+ const mode = localModeOf(store);
781
+ if (mode === null || !mode.enabled || who.via !== "cookie")
782
+ return null;
783
+ return { owner: who.authority !== "full", passwordSet: store.accountOf(who.name)?.passwordSet === true, canTurnOff: who.authority === "full" && operator };
784
+ })(),
778
785
  backup: lastBackup === null ? null : lastBackup.ok === false
779
786
  ? { failed: true, words: `Failed ${when(lastBackup.startedAt)}${lastBackup.error === null ? "" : `: ${lastBackup.error}`}` }
780
787
  : { failed: false, words: lastBackup.finishedAt === null ? "Backing up now" : `Backed up ${when(lastBackup.finishedAt)}` },
@@ -2448,14 +2455,35 @@ ${postForm("/settings/chat-approval/save", html `\
2448
2455
  }
2449
2456
  // Turn the lead on with the agent signed in on this computer: its membership spends no dollars, so like starting a
2450
2457
  // conversation it needs no second password; the full form (Advanced) still asks for one.
2458
+ // The welcome's "Use this one" names the agent (`agent`, claude or codex: only one signed in with its account here)
2459
+ // and comes back to the welcome (`return`, that one address only).
2451
2460
  async function leadOn(ctx) {
2452
- const { who, response, now } = ctx;
2461
+ const { who, response, now, posted } = ctx;
2462
+ const body = readForm(posted, CONSOLE_FORMS.leadOn);
2463
+ const asked = body.get("agent");
2464
+ if (body.sent.getAll("agent").length > 1 || (asked !== null && asked !== "claude" && asked !== "codex"))
2465
+ return refuse(response, who, 400, "Choose Claude Code or Codex.", "/settings/lead");
2466
+ const back = body.get("return") === "/welcome" ? "/welcome" : null;
2453
2467
  await checkLocalAgents(true);
2454
- const provider = runtime.localSignIn?.states.claude === "connected" ? "claude-subscription" : runtime.localSignIn?.states.codex === "connected" ? "codex-subscription" : null;
2455
- if (provider === null)
2456
- return redirect(response, chatReturnWithSaid("/settings/lead", `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.`));
2457
- store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
2458
- return redirect(response, "/chat");
2468
+ const states = runtime.localSignIn?.states;
2469
+ const provider = asked !== null ? (states?.[asked] === "connected" ? `${asked}-subscription` : null)
2470
+ : states?.claude === "connected" ? "claude-subscription" : states?.codex === "connected" ? "codex-subscription" : null;
2471
+ if (provider === null) {
2472
+ const words = asked === null ? `No agent is signed in on this computer yet. Run ${agentSignInCommand()}, then try again.` : `${asked === "claude" ? "Claude Code" : "Codex"} isn't signed in on this computer yet.`;
2473
+ return redirect(response, back ?? chatReturnWithSaid("/settings/lead", words));
2474
+ }
2475
+ // Choosing the one already in use changes nothing (its daily turns and the rest stay as set). The local owner's
2476
+ // choice also starts the installation's models on that agent's default preset, in the same write, while nothing
2477
+ // is chosen yet (model-choices.ts fillDefaultRouting): its first task can then be planned and approved without a
2478
+ // password. A full session's choice is as before: its models are Settings → Models'.
2479
+ store.transact(() => {
2480
+ if (store.getChatConfig()?.provider !== provider)
2481
+ store.setChatConfig({ provider, model: "default", dailyTurns: 50, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0 }, who.name, now);
2482
+ const agent = defaultAgentOf(provider);
2483
+ if (who.via === "cookie" && who.authority === "local-owner" && agent !== null)
2484
+ fillDefaultRouting(store, agent, who.name, { kind: "local-owner" }, now);
2485
+ });
2486
+ return redirect(response, back ?? "/chat");
2459
2487
  }
2460
2488
  /** Settings → Models, read from the saved catalog and the role rows at the scope being edited (the installation,
2461
2489
  * or one project and what it inherits). */
@@ -2648,6 +2676,7 @@ ${postForm("/settings/chat-approval/save", html `\
2648
2676
  "settings.sign-in": signInPage,
2649
2677
  "settings.passwordless": passwordlessPage,
2650
2678
  "settings.passwordless-send": passwordlessSave,
2679
+ "settings.local-mode-off": localModeOff,
2651
2680
  "settings.release-approval-send": releaseApprovalSave,
2652
2681
  "settings.lead": leadPage,
2653
2682
  "settings.teams": teamsPage,
@@ -2804,6 +2833,9 @@ ${postForm("/settings/chat-approval/save", html `\
2804
2833
  if (unavailable !== null)
2805
2834
  return back("problem", `${unavailable} Nothing changed.`);
2806
2835
  const widening = widerThan(wanted, store.projectCheckAccess(repo));
2836
+ // Easy-start C5: while local mode is on, this installation always confines; said before the password.
2837
+ if (wanted === "unsandboxed" && localModeOn(store))
2838
+ return back("problem", `${LOCAL_MODE_CONFINES} Nothing changed.`);
2807
2839
  // Sign-ins with an open network: a second, explicit yes, said before the password.
2808
2840
  if (wanted === "sign-ins" && widening && store.projectNetwork(repo).mode === "open" && body.get("confirm-open") !== "yes") {
2809
2841
  return back("problem", "This project's network is open: confirm that its checks could send your sign-ins to any host. Nothing changed.");
@@ -2950,5 +2982,28 @@ ${postForm("/settings/chat-approval/save", html `\
2950
2982
  const said = modelsSaid(result, isPresetName(preset) ? PRESETS[preset].label : `Switched to ${to}`);
2951
2983
  return back(said.key, said.words);
2952
2984
  }
2985
+ // Easy-start C5: turning local mode off (Settings → People and sign-in), from a full session only: an instance
2986
+ // operator's step-up, proved and written in one transaction. It bumps local mode's generation, so every local-owner
2987
+ // session, link and approval bound to the old one stops standing. It is never turned back on here.
2988
+ async function localModeOff(ctx) {
2989
+ const { response, now, posted } = ctx;
2990
+ const who = browserCaller(ctx.who);
2991
+ const body = readForm(posted, CONSOLE_FORMS.localModeOff);
2992
+ const back = sayTo(response, "/settings/access", "#local-mode");
2993
+ if (body.sent.getAll("password").length > 1)
2994
+ return back("problem", "That form was sent twice over. Nothing changed.");
2995
+ if (who.authority !== "full")
2996
+ return back("problem", "Sign in with your password to turn off local mode.");
2997
+ const turned = store.transact(() => {
2998
+ if (!authenticateApprover(who, body.get("password") ?? "").ok)
2999
+ return null;
3000
+ return setLocalModeOff(store, { name: who.name, authority: who.authority }, now);
3001
+ });
3002
+ if (turned === null)
3003
+ return back("problem", "Enter your Toolroll password to turn off local mode.");
3004
+ if (!turned.ok)
3005
+ return back("problem", turned.reason === "not-on" ? "Local mode is already off." : "Only an instance operator can turn off local mode.");
3006
+ return back("said", "Local mode is off. This computer's browser signs in with your password now.");
3007
+ }
2953
3008
  return { registrations, modelsView };
2954
3009
  }
@@ -5,7 +5,8 @@ import { RELEASE_STEPS } from "../release.js";
5
5
  import { releaseProgressFor, shipChecksForTask } from "../store.js";
6
6
  import { releaseCiRunningOf } from "../release-ci-hold.js";
7
7
  import { releaseObligationsOf } from "../release-recheck.js";
8
- import { approvalProofOf, freshlyApprovable, freshnessFor, withFreshness } from "../scope.js";
8
+ import { approvalProofOf, freshlyApprovable, freshnessFor, localApprovalEligible, withFreshness } from "../scope.js";
9
+ import { localOwnerRefusal } from "../local-owner.js";
9
10
  import { liveAttemptOf, steerControlsFor } from "../steer-controls.js";
10
11
  import { originOf } from "../task-source.js";
11
12
  import { recentGitReads, taskBaseOf } from "../task-base.js";
@@ -526,6 +527,14 @@ export function createTaskViews(runtime) {
526
527
  const gate = runtime.store.approvalGate(ref.externalId, who.name, "person");
527
528
  return freshlyApprovable(runtime.store, ref.externalId, scope, gate.verdict === "seal" && !gate.protectedWork) ? new Date(fresh.at).toISOString() : null;
528
529
  })(),
530
+ // Easy-start C5: a laptop's local owner approves eligible work with no password (the seal applies the same rule).
531
+ approvalLocal: (() => {
532
+ if (ref === null || who?.via !== "cookie" || who.authority !== "local-owner")
533
+ return null;
534
+ if (localApprovalEligible(runtime.store, who.name, ref.externalId).ok)
535
+ return { eligible: true };
536
+ return { eligible: false, ...localOwnerRefusal(runtime.store.accountOf(who.name)?.passwordSet === true, taskHref(ref.externalId)) };
537
+ })(),
529
538
  steerRecent: ref === null ? [] : runtime.store.runsFor(ref.id).filter(one => one.outcome !== null).map(one => one.id).sort((a, b) => b - a).slice(0, 5),
530
539
  // The running attempt's composer, for an approver on a browser session (steer-controls.ts).
531
540
  steerComposer: (() => {
@@ -54,3 +54,13 @@ export declare function createTidyHandlers(runtime: ServerRuntime): {
54
54
  export declare function retiredTasksAddress(route: "board" | "done", url: URL): string | null;
55
55
  /** How many cards a Board column shows; the column's count covers them all and its "View all" opens the list. */
56
56
  export declare const BOARD_COLUMN_LIMIT = 40;
57
+ /**
58
+ * Easy-start C1 (N1): the two writes a page load used to make, as keyed POSTs the shell sends (wave 1c wires the
59
+ * browser). `agents.seen`: a finished result this person opened leaves their unread Done. `agents.watch`: hold (on each
60
+ * keep-alive) or release (on unload) the short lease that quiets chat pings while this person's page shows the agent.
61
+ * Each re-proves the run is in the person's projects; a reply carries nothing else.
62
+ */
63
+ export declare function createAgentSignalHandlers(runtime: ServerRuntime): {
64
+ "agents.seen": (ctx: HandlerContext) => Promise<void>;
65
+ "agents.watch": (ctx: HandlerContext) => Promise<void>;
66
+ };