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
@@ -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
+ };
@@ -1,3 +1,4 @@
1
+ import { titleWithin } from "../task-text.js";
1
2
  import { parseFeedbackImage, sameFeedbackImage } from "../result-review.js";
2
3
  import { archiveTaskFamily, dropFeedbackImage, releaseAuthorityOfTask, restoreTaskFamily, revokeRelease, runLiveCommands } from "../store.js";
3
4
  import { applyTidy, approverStandsNow, familyRepoOf, releaseFactsOf, tidyDays, tidyDigest, tidyPreview, TIDY_DEFAULT_DAYS } from "../tidy.js";
@@ -58,14 +59,14 @@ import { structuredHandoffView, terminalDiffView } from "../result-evidence-read
58
59
  import { fileAddTestsTask, requestFollowUpChecks } from '../result-follow-ups.js';
59
60
  import { commentSourceKey, isRevisionFeedback, parseResultTab, resultReturnTarget } from "../result-review.js";
60
61
  import { isAlive as runnerAlive } from "../runner.js";
61
- import { passwordlessSessionFor, acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, freshnessFor, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
62
+ import { passwordlessSessionFor, acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, freshnessFor, localApprovalEligible, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
62
63
  import { subagentNames as subagentNamesOf } from "../spend.js";
63
64
  import { verifiedAuthor } from "../store.js";
64
65
  import { runCostWords } from "../summary.js";
65
66
  import { requestTaskStop, resumeTaskStop } from "../task-control.js";
66
67
  import { templateByName } from "../templates.js";
67
68
  import { TEXT_LIMITS } from "../text-limits.js";
68
- import { agentQuestion, answerAgentQuestion, markAgentSeen } from "../agent-board.js";
69
+ import { AGENT_VIEW_ID, agentQuestion, answerAgentQuestion, holdOwnAgentView, markAgentSeen, releaseOwnAgentView } from "../agent-board.js";
69
70
  import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
70
71
  import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
71
72
  import { requestContext } from "./request-context.js";
@@ -573,10 +574,8 @@ export function createTasksHandlers(runtime) {
573
574
  return sendScreen(response, 409, screen("Result changed", html `<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>`, { chrome: chromeFor(project, "runs") }));
574
575
  }
575
576
  const selected = picked !== null ? picked.selected : selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
576
- // Opening a run's own result page is viewing it: it leaves this person's unread Done on the Agents board — but only
577
- // once the run has finished (cockpit §3): a page shown while it was still running saw no finished version.
578
- if (wantedId !== null && selected?.run != null && store.getRun(selected.run.id)?.finishedAt != null)
579
- markAgentSeen(store, who.name, selected.run.id, now);
577
+ // Opening a run's own result page is viewing it: the shell says so with a keyed POST /agents/seen (easy-start N1:
578
+ // a page load never writes), which leaves this person's unread Done once the run has finished.
580
579
  const reviewPage = reviewCockpitPage(chromeFor(wantedId === null ? project : chosenProject, "runs"), {
581
580
  queue: ranked,
582
581
  queueCap: REVIEW_QUEUE_CAP,
@@ -1004,7 +1003,11 @@ export function createTasksHandlers(runtime) {
1004
1003
  }
1005
1004
  }
1006
1005
  const id = (body.get("id") ?? "").trim();
1007
- const title = body.get("title") ?? "";
1006
+ // The New task box stops at a title's length (render-new-task.ts): the title is all the planner reads of a no-goal
1007
+ // filing. A direct POST longer than that is cut at a whole word, with an ellipsis (task-text.ts titleWithin), as a
1008
+ // belt; a refusal keeps the person's own words (the draft below).
1009
+ const typed = body.get("title") ?? "";
1010
+ const title = titleWithin(typed);
1008
1011
  // The EFFECTIVE placement (repo onboarding, findings 15/35): the
1009
1012
  // trimmed, nonempty posted repo, else the open project — an empty
1010
1013
  // input falls through correctly. In root mode the effective path is
@@ -1021,7 +1024,7 @@ export function createTasksHandlers(runtime) {
1021
1024
  // direct POST with no project open must not mint an unplaced task
1022
1025
  // under a ceiling. Unscoped mode keeps its historic unplaced filings.
1023
1026
  // A refusal from here on is the add page again with the person's own words (the old task list kept them too).
1024
- const draft = { title, goal: body.get("goal") ?? "", not: body.get("not") ?? "", touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent };
1027
+ const draft = { title: typed, goal: body.get("goal") ?? "", not: body.get("not") ?? "", touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent };
1025
1028
  if (!unscopedMode && effective === "") {
1026
1029
  return newTaskScreen(ctx, 400, "name a repository — no project is open, so the task must say where it belongs", draft);
1027
1030
  }
@@ -2473,7 +2476,9 @@ export function createTasksHandlers(runtime) {
2473
2476
  }
2474
2477
  // An empty password stands for an explicit identity-provider re-check not yet spent, or (cockpit B2) a password
2475
2478
  // remembered on this session; approve() decides whether these terms may stand on a remembered one.
2476
- if (token === "" && !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null) {
2479
+ // Easy-start C5: a laptop's local owner approves eligible work with no password (approve() proves it again).
2480
+ if (token === "" && !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null
2481
+ && !(who.authority === "local-owner" && localApprovalEligible(store, who.name, taskId).ok)) {
2477
2482
  return approvalProblem("approval requires your password, typed again", 400);
2478
2483
  }
2479
2484
  const scopeRow = store.getScope(taskId);
@@ -3087,6 +3092,7 @@ export function createTasksHandlers(runtime) {
3087
3092
  }
3088
3093
  const registrations = handlersOf("tasks", {
3089
3094
  ...tidyHandlers,
3095
+ ...createAgentSignalHandlers(runtime),
3090
3096
  "code.page": showCode("workspace"),
3091
3097
  "code.session": showCode("session"),
3092
3098
  "code.state": showCode("state"),
@@ -3261,3 +3267,47 @@ export function retiredTasksAddress(route, url) {
3261
3267
  }
3262
3268
  /** How many cards a Board column shows; the column's count covers them all and its "View all" opens the list. */
3263
3269
  export const BOARD_COLUMN_LIMIT = 40;
3270
+ /**
3271
+ * Easy-start C1 (N1): the two writes a page load used to make, as keyed POSTs the shell sends (wave 1c wires the
3272
+ * browser). `agents.seen`: a finished result this person opened leaves their unread Done. `agents.watch`: hold (on each
3273
+ * keep-alive) or release (on unload) the short lease that quiets chat pings while this person's page shows the agent.
3274
+ * Each re-proves the run is in the person's projects; a reply carries nothing else.
3275
+ */
3276
+ export function createAgentSignalHandlers(runtime) {
3277
+ const { store, runVisible } = runtime;
3278
+ const answer = (response, status, ok) => respond(response, status, "application/json", JSON.stringify({ ok }));
3279
+ const visibleRun = (who, raw) => {
3280
+ const id = raw !== null && /^[1-9][0-9]{0,14}$/.test(raw) ? Number(raw) : null;
3281
+ const run = id === null ? null : store.getRun(id);
3282
+ const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
3283
+ return run === null || !runVisible(run) || !store.accountCanAccess(who.name, repo) ? null : run;
3284
+ };
3285
+ async function agentSeen(ctx) {
3286
+ const { who, response, now, posted } = ctx;
3287
+ const run = visibleRun(who, readForm(posted, CONSOLE_FORMS.agentSeen).get("run"));
3288
+ if (run === null)
3289
+ return answer(response, 404, false);
3290
+ // Only once it finished: a page shown while it ran saw no finished version (cockpit §3).
3291
+ if (run.finishedAt === null)
3292
+ return answer(response, 409, false);
3293
+ markAgentSeen(store, who.name, run.id, now);
3294
+ return answer(response, 200, true);
3295
+ }
3296
+ async function agentWatch(ctx) {
3297
+ const { who, response, now, posted } = ctx;
3298
+ const body = readForm(posted, CONSOLE_FORMS.agentWatch);
3299
+ const view = body.get("view") ?? "", op = body.get("op");
3300
+ if (!AGENT_VIEW_ID.test(view) || (op !== "hold" && op !== "release"))
3301
+ return answer(response, 400, false);
3302
+ if (op === "release") {
3303
+ releaseOwnAgentView(store, view, who.name, now);
3304
+ return answer(response, 200, true);
3305
+ }
3306
+ const run = visibleRun(who, body.get("run"));
3307
+ if (run === null)
3308
+ return answer(response, 404, false);
3309
+ const held = holdOwnAgentView(store, view, who.name, run.id, now);
3310
+ return answer(response, held ? 200 : 409, held);
3311
+ }
3312
+ return { "agents.seen": agentSeen, "agents.watch": agentWatch };
3313
+ }
@@ -17,6 +17,7 @@
17
17
  import { randomBytes } from "node:crypto";
18
18
  import { STATUS_CODES } from "node:http";
19
19
  import { dirname, join } from "node:path";
20
+ import { KEY_PROTOCOL } from "./mutation-key.js";
20
21
  import { WebSocketServer } from "ws";
21
22
  import { readForm, CONSOLE_FORMS } from "../contracts/console-api.js";
22
23
  import { html, htmlString } from "../html.js";
@@ -55,9 +56,14 @@ export function createTerminalHandlers(runtime, deps) {
55
56
  const transportOfRequest = (request) => transportOf({ peer: request.socket.remoteAddress, joinSource: deps.forwardedSourceOf(request),
56
57
  forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
57
58
  const sockets = new Set();
58
- const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES, perMessageDeflate: false });
59
+ // Easy-start C2: a page offers `toolroll` and, on a protected loopback install, `toolroll.key.<key>`; only `toolroll` is
60
+ // ever selected, so the key is never echoed.
61
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES, perMessageDeflate: false,
62
+ handleProtocols: protocols => protocols.has(KEY_PROTOCOL) ? KEY_PROTOCOL : false });
59
63
  /** An operator signed in with a browser, of the generation they signed in with; else null. */
60
- const operatorOf = (who) => who?.via === "cookie" && who.role === "approver" && !store.isDemo() && codingActorAllowed({ name: who.name, generation: who.session.generation }) ? who : null;
64
+ const operatorOf = (who) =>
65
+ // Easy-start C1: never a local owner's session (a laptop's passwordless owner), for a new terminal or an existing one.
66
+ who?.via === "cookie" && who.authority === "full" && who.role === "approver" && !store.isDemo() && codingActorAllowed({ name: who.name, generation: who.session.generation }) ? who : null;
61
67
  /** The project a terminal works in is one the coding workspace admits for this person, now. */
62
68
  const projectAllowed = (name, repo) => rowVisible(liveCeiling(), repo) && store.accountCanAccess(name, repo) && codingProjectAllowed(repo);
63
69
  const owned = (who, terminal) => terminal !== null && terminal.owner === who.name && terminal.generation === who.session.generation;
@@ -232,6 +238,13 @@ export function createTerminalHandlers(runtime, deps) {
232
238
  refuseUpgrade(socket, 401, "Sign in as an installation-wide operator.");
233
239
  return true;
234
240
  }
241
+ // Easy-start C2: typing into a terminal is a mutation: on a protected loopback install the socket offers the session's
242
+ // key (its protocol list), which a cookie holder on another port can't read.
243
+ const keyed = runtime.browserKeys.check(who, request, null);
244
+ if (keyed !== null) {
245
+ refuseUpgrade(socket, keyed.status, keyed.message);
246
+ return true;
247
+ }
235
248
  const terminal = deps.host?.get(id) ?? null;
236
249
  if (!owned(who, terminal) || !projectAllowed(who.name, terminal.repo)) {
237
250
  refuseUpgrade(socket, 404, "No such terminal.");
@@ -1,22 +1,28 @@
1
- import { type FirstRunStep } from "../first-run.js";
2
- import { type CheckStep } from "./kit.js";
3
- import type { ServerRuntime } from "./runtime.js";
1
+ import { type WelcomeModel } from "../first-run.js";
2
+ import { type Html } from "../html.js";
4
3
  import { type Chrome, type Screen } from "./chrome.js";
5
- export type WelcomeFacts = {
6
- steps: readonly FirstRunStep[];
7
- projects: number;
8
- /** This person has a live push enrollment on some device. */
9
- alerts: boolean;
4
+ import type { ServerRuntime } from "./runtime.js";
5
+ /** Why the sample project wasn't added, as a fixed key in the address (never words from it). */
6
+ export declare const SAMPLE_PROBLEMS: {
7
+ readonly "no-root": "There's no project folder to put the sample in. Add a folder instead.";
8
+ readonly exists: "A folder named toolroll-sample is already there and wasn't made here, so nothing was changed.";
9
+ readonly failed: "The sample project couldn't be made. Nothing was added. Try again, or add a folder instead.";
10
+ readonly "not-added": "The sample project was made but couldn't be added. Try again, or add it from Projects.";
10
11
  };
11
- /** The five steps as the page shows them, and which one leads. */
12
- export declare function welcomeSteps(facts: WelcomeFacts): {
13
- steps: CheckStep[];
14
- primary: {
15
- label: string;
16
- href: string;
17
- } | null;
12
+ export type SampleProblem = keyof typeof SAMPLE_PROBLEMS;
13
+ /** The page body for one model, and the screen's one primary (for the phone dock). `problem`: why the sample project
14
+ * wasn't added, from the address. `canAdd`: this person may add projects here. */
15
+ export declare function welcomeBody(model: WelcomeModel, options: {
16
+ problem?: SampleProblem | null;
17
+ canAdd: boolean;
18
+ }): {
19
+ body: Html;
20
+ primary: Html | null;
18
21
  };
19
- export declare function welcomePage(chrome: Chrome, facts: WelcomeFacts): Screen;
22
+ export declare function welcomePage(chrome: Chrome, model: WelcomeModel, options: {
23
+ problem?: SampleProblem | null;
24
+ canAdd: boolean;
25
+ }): Screen;
20
26
  export declare function createWelcomeHandlers(runtime: ServerRuntime): {
21
27
  registrations: import("./handler-registry.js").Registration[];
22
28
  };