toolroll 0.9.89 → 0.9.91

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 (138) hide show
  1. package/CHANGELOG.md +63 -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-adapters.d.ts +2 -0
  69. package/dist/release-adapters.js +30 -1
  70. package/dist/release-cli.js +2 -1
  71. package/dist/runner-process.d.ts +9 -0
  72. package/dist/runner-process.js +18 -0
  73. package/dist/sample-project.d.ts +31 -0
  74. package/dist/sample-project.js +141 -0
  75. package/dist/scope.d.ts +24 -2
  76. package/dist/scope.js +74 -3
  77. package/dist/serve.js +129 -22
  78. package/dist/server/chat.js +10 -3
  79. package/dist/server/chrome.d.ts +10 -2
  80. package/dist/server/chrome.js +7 -5
  81. package/dist/server/console.css +10 -0
  82. package/dist/server/guards.d.ts +3 -0
  83. package/dist/server/guards.js +22 -2
  84. package/dist/server/http.d.ts +22 -0
  85. package/dist/server/http.js +58 -0
  86. package/dist/server/live.js +4 -14
  87. package/dist/server/mutation-key.d.ts +70 -0
  88. package/dist/server/mutation-key.js +142 -0
  89. package/dist/server/pages.js +10 -7
  90. package/dist/server/people-tokens.js +240 -20
  91. package/dist/server/remote.d.ts +2 -0
  92. package/dist/server/remote.js +19 -4
  93. package/dist/server/render-new-task.js +5 -1
  94. package/dist/server/render-pages.d.ts +2 -1
  95. package/dist/server/render-pages.js +1 -1
  96. package/dist/server/render-people.d.ts +36 -0
  97. package/dist/server/render-people.js +65 -1
  98. package/dist/server/render-projects.js +4 -2
  99. package/dist/server/render-tasks.d.ts +12 -0
  100. package/dist/server/render-tasks.js +12 -4
  101. package/dist/server/request-context.d.ts +6 -0
  102. package/dist/server/route-table.d.ts +16 -0
  103. package/dist/server/route-table.js +121 -0
  104. package/dist/server/runtime.d.ts +6 -4
  105. package/dist/server/session.d.ts +31 -1
  106. package/dist/server/session.js +12 -2
  107. package/dist/server/settings.js +63 -8
  108. package/dist/server/task-views.js +10 -1
  109. package/dist/server/tasks.d.ts +10 -0
  110. package/dist/server/tasks.js +59 -9
  111. package/dist/server/terminals.js +15 -2
  112. package/dist/server/welcome.d.ts +22 -16
  113. package/dist/server/welcome.js +150 -52
  114. package/dist/settings-groups-ui.d.ts +7 -1
  115. package/dist/settings-groups-ui.js +23 -2
  116. package/dist/settings-proofs.js +1 -0
  117. package/dist/setup-guide.d.ts +2 -2
  118. package/dist/store.d.ts +124 -1
  119. package/dist/store.js +245 -12
  120. package/dist/surface.d.ts +10 -0
  121. package/dist/surface.js +40 -1
  122. package/dist/task-text.d.ts +6 -0
  123. package/dist/task-text.js +17 -0
  124. package/dist/telegram-lead.js +2 -0
  125. package/dist/terminal-text.d.ts +11 -0
  126. package/dist/terminal-text.js +26 -0
  127. package/dist/toolroll-update.d.ts +8 -1
  128. package/dist/toolroll-update.js +151 -4
  129. package/dist/tui-contract.d.ts +118 -0
  130. package/dist/tui-contract.js +9 -0
  131. package/dist/tui-source.d.ts +14 -0
  132. package/dist/tui-source.js +182 -0
  133. package/dist/tui.js +129 -0
  134. package/dist/up-processes.d.ts +13 -1
  135. package/dist/up-processes.js +13 -3
  136. package/dist/welcome-facts.d.ts +18 -0
  137. package/dist/welcome-facts.js +79 -0
  138. package/package.json +4 -2
@@ -1,69 +1,167 @@
1
1
  /**
2
- * The first run's own page, /welcome (Wave E, src/design/KIT.md): five guided steps to a first result, each derived
3
- * from live facts on every render (never a stored cursor), each with the one action that does it. The next required
4
- * step's action is the screen's one primary (on a fresh install: Add a project); alerts on the phone are optional and
5
- * never lead. It only reads: every step's action is a link to the page that does it. Once the first result has
6
- * arrived (or for a project-limited account, or in the demo) the first run is over and the address leads Home.
2
+ * The first run's own page, /welcome (src/design/KIT.md): three steps to a first result, Agent · Project · First task,
3
+ * told by first-run.ts's one model (welcomeModel) from live facts on every render, never a stored cursor. The page
4
+ * reads; its acts are the ones that already own each change: choosing the agent posts to Settings → Lead's own
5
+ * turn-on (/settings/lead/on), a folder and GitHub link to Projects, and the sample project is made and added here
6
+ * (POST /welcome/sample) the way Projects adds a folder (project-admission.ts). A first task's starter only drafts its
7
+ * words into New task's box: nothing is filed until the person sends it. Once the first result has arrived (or for a
8
+ * project-limited account, or in the demo) the address leads Home.
7
9
  */
10
+ import { resolvePhaseAgent } from "../agentconfig.js";
11
+ import { CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
8
12
  import { builderReadyFor, firstRunBuilderRepo } from "../dispatch.js";
9
- import { withBuilderStep } from "../first-run.js";
10
- import { html } from "../html.js";
13
+ import { firstTaskSuggestions, SAMPLE_FIRST_TASKS, SAMPLE_PROJECT_NAME, welcomeModel, withBuilderStep } from "../first-run.js";
14
+ import { html, joinHtml, postForm } from "../html.js";
11
15
  import { START_BUILDER_HREF } from "../needs-you.js";
16
+ import { admitProject } from "../project-admission.js";
17
+ import { authorizedProject } from "../project.js";
18
+ import { createSampleProject, registerSampleCheck } from "../sample-project.js";
19
+ import { refuse, screen } from "./chrome.js";
12
20
  import { handlersOf } from "./handler-registry.js";
13
21
  import { redirect } from "./http.js";
14
- import { btn, checklist, dock, pageHeader } from "./kit.js";
15
- import { screen } from "./chrome.js";
22
+ import { btn, chip, codeLine, dock, note, notice, pageHeader, settingsRecord } from "./kit.js";
23
+ /** Why the sample project wasn't added, as a fixed key in the address (never words from it). */
24
+ export const SAMPLE_PROBLEMS = {
25
+ "no-root": "There's no project folder to put the sample in. Add a folder instead.",
26
+ exists: `A folder named ${SAMPLE_PROJECT_NAME} is already there and wasn't made here, so nothing was changed.`,
27
+ failed: "The sample project couldn't be made. Nothing was added. Try again, or add a folder instead.",
28
+ "not-added": "The sample project was made but couldn't be added. Try again, or add it from Projects.",
29
+ };
30
+ const isSampleProblem = (value) => value !== null && Object.hasOwn(SAMPLE_PROBLEMS, value);
16
31
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
17
- /** The five steps as the page shows them, and which one leads. */
18
- export function welcomeSteps(facts) {
19
- const step = (key) => facts.steps.find(one => one.key === key);
20
- const agent = step("agent"), builder = step("builder"), task = step("task");
21
- const projectDone = facts.projects > 0;
22
- const agentDone = (agent?.done ?? false) && (builder?.done ?? true);
23
- const command = agent?.action?.kind === "command" ? agent.action.command : null;
24
- const agentState = agentDone ? "done" : agent?.checking === true ? "checking" : "open";
25
- const agentText = agentDone ? "An agent is signed in and a builder is ready."
26
- : agent?.checking === true ? "Checking for a signed-in agent on this computer…"
27
- : agent !== undefined && !agent.done && command !== null ? html `On the computer running Toolroll, run <code class="k-mono">${command}</code>.`
28
- : "Start a builder so tasks can run.";
29
- const agentLink = agentDone || agent?.checking === true ? null
30
- : agent !== undefined && !agent.done ? { label: "Agent settings", href: "/settings/models" } : { label: "Start a builder", href: START_BUILDER_HREF };
31
- const taskDone = task?.done ?? false;
32
- // The next required step leads (alerts are optional and never do).
33
- const order = [
34
- { key: "project", open: !projectDone, link: { label: "Add a project", href: "/projects" } },
35
- { key: "agent", open: agentState === "open", link: agentLink },
36
- { key: "task", open: !taskDone && projectDone, link: { label: "New task", href: "/tasks/new" } },
37
- ];
38
- const lead = order.find(one => one.open && one.link !== null) ?? null;
39
- const action = (key, link) => link === null ? null : btn(link.label, { sm: true, href: link.href, ...(lead?.key === key ? { variant: "primary", docked: true } : {}) });
40
- const steps = [
41
- { key: "password", title: "Your password", text: "You're signed in. Your first login is saved on the computer running Toolroll.", state: "done" },
42
- { key: "project", title: "Add a project", text: projectDone ? plural(facts.projects, "project added.", "projects added.") : "A git repository agents work in.", state: projectDone ? "done" : "open", action: action("project", order[0].link) },
43
- { key: "agent", title: "Agent and models", text: agentText, state: agentState, action: action("agent", agentLink) },
44
- { key: "phone", title: "Alerts on your phone", text: facts.alerts ? "Alerts are on." : "Optional. Hear when something needs you.", state: facts.alerts ? "done" : "open", action: btn("Set up alerts", { sm: true, href: "/settings/alerts#this-device" }) },
45
- { key: "task", title: "Your first task", text: taskDone ? "Filed. Its plan waits for your approval." : "Describe a change and approve its plan.", state: taskDone ? "done" : "open",
46
- action: projectDone ? action("task", order[2].link) : null },
47
- ];
48
- return { steps, primary: lead?.link ?? null };
32
+ const AGENT_WORDS = { "signed-in": "Signed in", "signed-out": "Not signed in", "not-installed": "Not installed", checking: "Checking…" };
33
+ const PROJECTS_SHOWN = 3;
34
+ const ADD_FOLDER = "/projects?return=%2Fwelcome";
35
+ /** One step: its number and name, Done once it is, then its body. */
36
+ function step(key, index, title, done, body) {
37
+ return html `<section class="k-sec welcome-step" id="welcome-${key}" data-step="${key}" data-done="${done ? "1" : "0"}" aria-labelledby="welcome-${key}-h">\
38
+ <div class="k-secthead"><h3 id="welcome-${key}-h">${index}. ${title}</h3>${done ? chip("Done", "done") : ""}</div>${body}</section>`;
49
39
  }
50
- export function welcomePage(chrome, facts) {
51
- const { steps, primary } = welcomeSteps(facts);
52
- const left = steps.filter(one => one.state !== "done").length;
53
- const body = html `${pageHeader("Welcome")}<div class="k-doc"><div class="k-hello"><h2>Get to your first result.</h2><p>${left === 0 ? "Setup is done." : `${plural(left, "step", "steps")} left.`}</p></div>\
54
- ${checklist(steps, "Setup steps")}</div>${dock(primary === null ? null : btn(primary.label, { variant: "primary", href: primary.href }))}`;
55
- return screen("Welcome", body, { chrome });
40
+ /** "Use this one": Settings → Lead's own turn-on, for exactly this agent, back here. */
41
+ const useForm = (agent, button) => postForm("/settings/lead/on", button, { hidden: { agent, return: "/welcome" } });
42
+ function agentRow(agent, leads) {
43
+ const sub = agent.command === null ? AGENT_WORDS[agent.state]
44
+ : html `${AGENT_WORDS[agent.state]}. To sign in, run this on the computer running Toolroll:${codeLine(agent.command)}`;
45
+ const action = agent.inUse ? chip("In use", "", { dot: false })
46
+ : agent.canUse ? useForm(agent.id, btn("Use this one", { sm: true, type: "submit", ...(leads ? { variant: "primary", docked: true } : {}) }))
47
+ : "";
48
+ return settingsRecord({ title: agent.name, sub, action, attrs: { "data-agent": agent.id, "data-state": agent.state } });
56
49
  }
50
+ /** The page body for one model, and the screen's one primary (for the phone dock). `problem`: why the sample project
51
+ * wasn't added, from the address. `canAdd`: this person may add projects here. */
52
+ export function welcomeBody(model, options) {
53
+ const lead = model.next;
54
+ let primary = null;
55
+ const agentLead = lead === "agent" ? model.agent.agents.find(one => one.canUse) ?? null : null;
56
+ if (agentLead !== null)
57
+ primary = useForm(agentLead.id, btn(`Use ${agentLead.name}`, { variant: "primary", type: "submit" }));
58
+ const agentNote = model.agent.checking ? note("Checking for a signed-in agent on this computer…")
59
+ : !model.agent.done ? note("Sign in to one of these, then come back to this page.")
60
+ : model.agent.buildsWith !== null ? note(html `Tasks are built with ${model.agent.buildsWith}. <a class="k-link" href="/settings/models">Change</a>`) : "";
61
+ const agent = step("agent", 1, "Agent", model.agent.done, html `<p class="k-note">The AI coding agent that plans and builds your tasks.</p>\
62
+ <div class="welcome-rows">${model.agent.agents.map(one => agentRow(one, agentLead?.id === one.id))}</div>${agentNote}`);
63
+ const projects = model.project.projects;
64
+ const listed = projects.slice(0, PROJECTS_SHOWN).map(one => settingsRecord({ title: one.name, sub: html `<span class="k-mono">${one.path}</span>`, attrs: { "data-project": "" } }));
65
+ const more = projects.length > PROJECTS_SHOWN ? note(html `And ${plural(projects.length - PROJECTS_SHOWN, "more project", "more projects")} in <a class="k-link" href="/projects">Projects</a>.`) : "";
66
+ const working = lead === "project" && model.project.notTakingWork;
67
+ const folderLeads = lead === "project" && !working;
68
+ const folderLabel = projects.length === 0 ? "Add a folder" : "Add another folder";
69
+ if (folderLeads)
70
+ primary = btn(folderLabel, { variant: "primary", href: ADD_FOLDER });
71
+ if (working)
72
+ primary = btn("Start taking work", { variant: "primary", href: START_BUILDER_HREF });
73
+ const adds = joinHtml([
74
+ model.project.notTakingWork ? btn("Start taking work", { sm: true, href: START_BUILDER_HREF, ...(working ? { variant: "primary", docked: true } : {}) }) : "",
75
+ btn(folderLabel, { sm: true, href: ADD_FOLDER, ...(folderLeads ? { variant: "primary", docked: true } : {}) }),
76
+ options.canAdd ? btn("From GitHub", { sm: true, href: "/projects/github" }) : "",
77
+ options.canAdd && model.project.sample !== null ? postForm("/welcome/sample", btn("Try a sample project", { sm: true, type: "submit" }), { attrs: { class: "welcome-form" } }) : "",
78
+ ]);
79
+ const projectLine = projects.length === 0 ? "A git repository on this computer for agents to work in."
80
+ : model.project.notTakingWork ? `${plural(projects.length, "project added", "projects added")}. Tasks can't start yet: this computer isn't taking work for them.`
81
+ : plural(projects.length, "project added.", "projects added.");
82
+ const sampleWhere = projects.length === 0 && options.canAdd && model.project.sample !== null
83
+ ? note(html `The sample is a tiny repository, made at <span class="k-mono">${model.project.sample}</span>.`) : "";
84
+ const project = step("project", 2, "Project", model.project.done, html `<p class="k-note">${projectLine}</p>\
85
+ ${listed.length === 0 ? "" : html `<div class="welcome-rows">${listed}</div>`}${more}${options.problem ? notice(SAMPLE_PROBLEMS[options.problem]) : ""}\
86
+ <div class="k-acts welcome-acts">${adds}</div>${sampleWhere}`);
87
+ const taskLeads = lead === "task";
88
+ if (taskLeads)
89
+ primary = btn("Write a task", { variant: "primary", href: "/tasks/new" });
90
+ const taskBody = model.task.done ? html `<p class="k-note">Filed. Its plan waits for your approval in <a class="k-link" href="/work">Tasks</a>.</p>`
91
+ : !model.task.open ? html `<p class="k-note">Add a project first.</p>`
92
+ : html `<p class="k-note">Describe a change in a sentence or two. Nothing starts until you approve its plan.</p>\
93
+ ${model.task.starters.length === 0 ? "" : html `<div class="k-ideas" aria-label="Ideas to start from" data-starters>${model.task.starters.map(one => html `<a class="k-btn k-btn-outline k-idea" href="${one.href}">${one.label}</a>`)}</div>`}\
94
+ <div class="k-acts">${btn("Write a task", { sm: true, href: "/tasks/new", ...(taskLeads ? { variant: "primary", docked: true } : {}) })}</div>`;
95
+ const task = step("task", 3, "First task", model.task.done, taskBody);
96
+ const hello = html `<div class="k-hello"><h2>Get to your first result.</h2><p>${model.left === 0 ? "Setup is done." : `${plural(model.left, "step", "steps")} left.`}</p></div>`;
97
+ return { body: html `${hello}${agent}${project}${task}`, primary };
98
+ }
99
+ export function welcomePage(chrome, model, options) {
100
+ const { body, primary } = welcomeBody(model, options);
101
+ return screen("Welcome", html `${pageHeader("Welcome")}<div class="k-doc welcome" data-welcome>${body}</div>${dock(primary)}`, { chrome });
102
+ }
103
+ const AGENT_OF = { "claude-subscription": "claude", "codex-subscription": "codex" };
57
104
  export function createWelcomeHandlers(runtime) {
58
- const { store, sendScreen, chromeFor, firstRunStepsNow, managedRepos, visible } = runtime;
105
+ const { store, sendScreen, chromeFor, firstRunStepsNow, managedRepos, visible, liveCeiling, identities, deletedRepos, options, sessions } = runtime;
59
106
  async function showWelcome(ctx) {
60
- const { who, response, now, project } = ctx;
107
+ const { who, response, now, project, url } = ctx;
61
108
  const steps = withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now));
62
109
  // The first run is over (a first result, a project-limited account, the demo): the address leads Home.
63
110
  if (steps === null)
64
111
  return redirect(response, "/");
65
- const alerts = who.via === "cookie" && store.listPushSubscriptions(who.name).some(one => one.retiredAt === null);
66
- return sendScreen(response, 200, welcomePage(chromeFor(project, "work"), { steps, projects: managedRepos().length, alerts }));
112
+ // The projects the sidebar lists (a sample added here included).
113
+ const chrome = chromeFor(project, "work");
114
+ const projects = (chrome.projects ?? []).map(one => ({ name: one.name, path: one.path }));
115
+ const repo = project ?? projects[0]?.path ?? null;
116
+ const root = liveCeiling().roots[0] ?? null;
117
+ const sample = root === null ? null : `${root.replace(/\/+$/, "")}/${SAMPLE_PROJECT_NAME}`;
118
+ const starters = repo === null ? [] : repo === sample ? SAMPLE_FIRST_TASKS : runtime.firstTasks.get(repo)?.found ?? firstTaskSuggestions({ issues: [], todos: [] });
119
+ const states = runtime.localSignIn?.states ?? null;
120
+ const build = resolvePhaseAgent(store, "build", repo, {});
121
+ const approver = who.via === "cookie" && who.role === "approver";
122
+ const model = welcomeModel({
123
+ agents: { claude: states?.claude ?? null, codex: states?.codex ?? null },
124
+ inUse: AGENT_OF[store.getChatConfig()?.provider ?? ""] ?? null,
125
+ buildsOn: build.ok ? build.spec.provider : null,
126
+ canChoose: approver,
127
+ projects, projectRoot: root,
128
+ ready: steps.find(one => one.key === "builder")?.done ?? false,
129
+ hasTask: steps.find(one => one.key === "task")?.done ?? false,
130
+ starters, platform: options.platform ?? process.platform,
131
+ });
132
+ const asked = url.searchParams.get("sample");
133
+ return sendScreen(response, 200, welcomePage(chrome, model, { problem: isSampleProblem(asked) ? asked : null, canAdd: approver && !store.isDemo() }));
134
+ }
135
+ /** Try a sample project: made in the project folder (or found again when it is ours), then added the way Projects
136
+ * adds a folder, and opened. A refusal comes back to the welcome as a fixed key. */
137
+ async function addSample(ctx) {
138
+ const { who, response, now, posted } = ctx;
139
+ readForm(posted, CONSOLE_FORMS.welcomeSample);
140
+ if (who.via !== "cookie")
141
+ return refuse(response, who, 403, "adding a project is a browser session's act");
142
+ if (store.isDemo())
143
+ return refuse(response, who, 403, "the sandbox never makes projects");
144
+ const back = (problem) => redirect(response, `/welcome?sample=${problem}#welcome-project`);
145
+ const made = await createSampleProject(liveCeiling().roots[0] ?? null);
146
+ if (!made.ok)
147
+ return back(made.reason);
148
+ // Added exactly as Projects adds a folder: inside what this console serves, visible to this person, a git repository.
149
+ const decidedOn = identities.generation();
150
+ const canonical = await identities.resolveNow(made.path);
151
+ if (canonical === null || canonical === "no-answer" || !(await authorizedProject(liveCeiling(), canonical)) || !visible(canonical))
152
+ return back("not-added");
153
+ const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
154
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
155
+ if (!admitted.ok || !stillAllowed(admitted.repo))
156
+ return back("not-added");
157
+ // Its own fixed check (sample-project.ts), so the first result reads checked rather than "Checks not run": only for a
158
+ // sample this request just made, never one found again.
159
+ registerSampleCheck(store, made, admitted.repo, who.name, now);
160
+ deletedRepos.delete(admitted.repo);
161
+ who.session.project = admitted.repo;
162
+ who.session.projectRevision++;
163
+ sessions.persist(who.session);
164
+ return redirect(response, "/welcome");
67
165
  }
68
- return { registrations: handlersOf("tasks", { welcome: showWelcome }) };
166
+ return { registrations: handlersOf("tasks", { welcome: showWelcome, "welcome.sample": addSample }) };
69
167
  }
@@ -134,6 +134,13 @@ export type SettingsFacts = {
134
134
  failed: boolean;
135
135
  words: string;
136
136
  } | null;
137
+ /** Easy-start: local mode is on for this laptop install. `owner`: this is the local owner's own session (no password);
138
+ * `passwordSet`: the owner has set one; `canTurnOff`: a full session of an instance operator. Absent when it is off. */
139
+ local?: {
140
+ owner: boolean;
141
+ passwordSet: boolean;
142
+ canTurnOff: boolean;
143
+ } | null;
137
144
  };
138
145
  declare const groupHref: (slug: string) => string;
139
146
  export type NeedsItem = {
@@ -146,7 +153,6 @@ export type NeedsItem = {
146
153
  export declare function settingsNeeds(facts: SettingsFacts): NeedsItem[];
147
154
  /** Which groups have something waiting, for the section nav's dot and the overview's rows. */
148
155
  export declare function needsByGroup(items: readonly NeedsItem[]): Record<string, number>;
149
- /** The overview: what waits on this person, then one row per group with its state. Viewers see the groups they can open. */
150
156
  export declare function settingsHub(facts: SettingsFacts): Html;
151
157
  /** The hub's old anchors, forwarded to their group (fragments never reach the server). */
152
158
  export declare const HUB_ANCHORS: Readonly<Record<string, string>>;
@@ -84,6 +84,26 @@ function systemWords(facts) {
84
84
  return [facts.backup?.failed ? "The last backup failed" : null, version, "backups, storage, retention, monitoring"].filter(Boolean).join(" · ");
85
85
  }
86
86
  /** The overview: what waits on this person, then one row per group with its state. Viewers see the groups they can open. */
87
+ /**
88
+ * Easy-start C5: local mode, said once. The local owner without a password is offered the one way to more ("Let agents
89
+ * work on their own" → /password/set); a full session of an operator sees it on, and can turn it off (a step-up, confirmed).
90
+ */
91
+ function localModeSection(facts) {
92
+ const local = facts.local;
93
+ if (local === undefined || local === null)
94
+ return html ``;
95
+ if (local.owner) {
96
+ return local.passwordSet ? html `` : settingsSection({ id: "set-password", title: "Let agents work on their own",
97
+ lead: "Agents can work on their own, merge and release only after you set a password.",
98
+ body: html `<div class="k-acts">${btn("Set a password", { sm: true, href: "/password/set?next=%2Fsettings" })}</div>` });
99
+ }
100
+ if (!local.canTurnOff)
101
+ return html ``;
102
+ return settingsSection({ id: "local-mode", title: "Local mode", state: " · on",
103
+ lead: "A browser on this computer can file tasks, approve plans and add projects without your password.",
104
+ body: kitSectionForm({ proof: "local-mode-off", action: "/settings/local-mode/off", id: "local-mode-form", save: { label: "Turn off", variant: "outline" },
105
+ confirm: "Turn off local mode", body: html `` }) });
106
+ }
87
107
  export function settingsHub(facts) {
88
108
  const items = settingsNeeds(facts);
89
109
  const counts = needsByGroup(items);
@@ -104,6 +124,7 @@ export function settingsHub(facts) {
104
124
  body: joinHtml([
105
125
  answered(facts),
106
126
  panel,
127
+ facts.local?.owner === true ? localModeSection(facts) : "",
107
128
  settingsLinks(null, shown.map(([label, slug, href]) => settingsLink({ href, title: label, status: words[slug] ?? "", icon: SETTINGS_GROUP_ICONS[slug], needs: counts[slug] ?? 0 }))),
108
129
  ]),
109
130
  });
@@ -144,7 +165,7 @@ export function generalGroup(facts, flags) {
144
165
  id: "your-password", title: "Your password",
145
166
  body: joinHtml([
146
167
  formRow("Where it is", "If you started Toolroll with toolroll up, your password is saved on the computer running Toolroll. Toolroll never shows it in a browser. To read it, run this there:", html ``, "wide"),
147
- codeLine("cat ~/.config/toolroll/up-login.txt"),
168
+ codeLine("cat ~/.config/toolroll/.toolroll-secrets/up-login.txt"),
148
169
  facts.passwordless === null ? "" : formRow("Passwordless", "Sign in once; most approvals then need no password.", btn(`${facts.passwordless ? "On" : "Off"} · Change`, { sm: true, href: "/settings/passwordless" }), "inline"),
149
170
  ]),
150
171
  });
@@ -422,7 +443,7 @@ export function accessGroup(facts, flags) {
422
443
  ] : []),
423
444
  ];
424
445
  return settingsShell({ group: "access", title: "People and sign-in", lead: "Who can get in, how they sign in, and the ways in they hold.", flags,
425
- body: joinHtml([answered(facts), settingsLinks(null, links, { label: "People and sign-in" })]) });
446
+ body: joinHtml([answered(facts), localModeSection(facts), settingsLinks(null, links, { label: "People and sign-in" })]) });
426
447
  }
427
448
  function updatesSection(updates) {
428
449
  const latest = updates.latest;
@@ -67,6 +67,7 @@ export const PROOF_INVENTORY = [
67
67
  { id: "people-close", action: "Cancel an invite or remove a person", routes: ["/people/invite-revoke", "/people/revoke"], form: "peopleRevoke", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver", secrets: [], terms: [] },
68
68
  { id: "project-setup", action: "Approve project setup", routes: ["/control/setup-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project; models for a new installation need an instance operator", secrets: [], terms: ["repo", "nonce", "fingerprint", "provider", "model", "command", "seconds"], note: "the nonce is spent before the password is checked: after a refusal, review again; the reviewed inputs are previewed again and must match the fingerprint" },
69
69
  { id: "project-instructions", action: "Add the agent instructions", routes: ["/control/instructions-approve"], form: "projectSetup", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: ["repo", "nonce", "fingerprint"], note: "the nonce is spent before the password is checked" },
70
+ { id: "local-mode-off", action: "Turn off local mode", routes: ["/settings/local-mode/off"], form: "localModeOff", field: "password", proof: "step-up", ...ACCEPT, role: "operator", handlerRole: "a full session (never the local owner)", secrets: [], terms: [] },
70
71
  { id: "mode-sign", action: "Sign an operating mode", routes: ["/mode/sign"], form: "mode", field: "token", proof: "step-up", ...ACCEPT, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
71
72
  { id: "mode-sign-chat-approve", action: "Sign an operating mode that lets your chat app approve", routes: ["/mode/sign"], select: { field: "chat-approve", value: "1" }, form: "mode", field: "token", proof: "credential", ...CREDENTIAL, role: "any", handlerRole: "an approver of the project", secrets: [], terms: MODE_TERMS },
72
73
  // Credential acts: a password typed now or a one-use re-check; never Passwordless.
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/store.d.ts CHANGED
@@ -17,6 +17,7 @@ import { type Actor } from "./actor.js";
17
17
  import { type LiveActivityKind } from "./live.js";
18
18
  import { type CredentialKind, type TokenPurpose } from "./api-tokens.js";
19
19
  import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
20
+ import { type Authority, type LocalMode } from "./local-owner.js";
20
21
  import { type ScopeAuthor, type ExecutionProfile, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
21
22
  import { type ProviderRoom, type CandidateTier, type TaskSize, type TaskSizing, type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RouteOverride } from "./phase-routing.js";
22
23
  import type { TerminalClass } from "./exhaustion.js";
@@ -233,7 +234,7 @@ export type SealedLedgerEntry = LedgerEntry & {
233
234
  hash: string;
234
235
  } | null;
235
236
  };
236
- export declare const SCHEMA_VERSION = 146;
237
+ export declare const SCHEMA_VERSION = 147;
237
238
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
238
239
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
239
240
  /**
@@ -411,6 +412,21 @@ export declare const V145_SCHEMA = "\nCREATE TABLE IF NOT EXISTS task_archive (\
411
412
  * started, when it ended and how; recording it never changes the release. Both are read-only history for the Ship and
412
413
  * Release views and their CLI twins. */
413
414
  export declare const V146_SCHEMA = "\nCREATE TABLE IF NOT EXISTS ci_check (\n github_repo TEXT NOT NULL,\n pr_number INTEGER NOT NULL,\n head_sha TEXT NOT NULL,\n -- A check's identity: its kind (check run or commit status), its workflow and its name, so two workflows' checks\n -- with the same name stay two rows (a failing one never hides behind a passing namesake).\n ident TEXT NOT NULL,\n name TEXT NOT NULL,\n workflow TEXT,\n state TEXT NOT NULL CHECK (state IN ('queued','running','passed','failed','skipped','cancelled','neutral')),\n started_at TEXT,\n completed_at TEXT,\n url TEXT,\n observed_at TEXT NOT NULL,\n PRIMARY KEY (github_repo, pr_number, head_sha, ident)\n);\nCREATE TABLE IF NOT EXISTS release_step (\n task_id TEXT NOT NULL,\n tag TEXT NOT NULL,\n version TEXT NOT NULL,\n pr INTEGER,\n step TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('running','done','failed','waiting')),\n started_at TEXT NOT NULL,\n ended_at TEXT,\n detail TEXT,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (task_id, tag, step)\n);\nCREATE INDEX IF NOT EXISTS release_step_by_task ON release_step (task_id, updated_at);\n";
415
+ /** v147 (easy-start plan F): local mode, one row. Absent is off. Written only by initLocalMode (a database `up --laptop`
416
+ * created, with no account yet) and changed only through enrollOwnerPassword's precondition and setLocalModeOff, each
417
+ * of which moves `generation` when it changes what a local-owner session may stand on. `enabled` is the plan's `on`
418
+ * (ON is an SQL keyword). */
419
+ export declare const LOCAL_MODE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS local_mode (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n owner TEXT NOT NULL REFERENCES approver(name),\n generation INTEGER NOT NULL CHECK (generation >= 1),\n enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),\n initialized_at TEXT NOT NULL,\n changed_at TEXT NOT NULL\n);\n";
420
+ /** v147: an account's password flag, a browser session's authority and local binding, and the console's key epoch.
421
+ * Every saved row reads the declared value: an account has a password (1), a session is full ('full') with no local
422
+ * generation, no key epoch (stale: the next mutation asks to reconnect) and no key; the console record has no epoch. */
423
+ export declare const V147_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
424
+ definition: string;
425
+ saved: string;
426
+ }>>>>;
427
+ /** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
428
+ export declare const V147_STEPS: readonly ["epoch", "local-mode", "password-set", "web-session", "console-instance"];
429
+ export type MigrationStep = (typeof V147_STEPS)[number];
414
430
  /**
415
431
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
416
432
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -503,6 +519,12 @@ export type WebSessionRow = {
503
519
  ssoAt: number | null;
504
520
  agent: string | null;
505
521
  address: string | null;
522
+ /** v147: the session's authority (saved rows read 'full'); a local owner's local-mode generation; the console start its
523
+ * mutation key belongs to and a hash of that key (never the key). */
524
+ authority: Authority;
525
+ localGeneration: number | null;
526
+ keyEpoch: string | null;
527
+ keyHash: string | null;
506
528
  };
507
529
  /**
508
530
  * Every timestamp column holds `Date.prototype.toISOString()` output and
@@ -1861,6 +1883,11 @@ export declare function databasePath(env: Record<string, string | undefined>, ho
1861
1883
  export type OpenOptions = {
1862
1884
  /** Substituted in tests; production opens node:sqlite itself. */
1863
1885
  connect?: (file: string) => Database;
1886
+ /** v147: who created a NEW database. "laptop" (`up --laptop`) records installation_fact created-by=laptop in the same
1887
+ * transaction as installed-at, the one fact initLocalMode starts from. Ignored for a database that already exists. */
1888
+ createdBy?: "laptop";
1889
+ /** Test seam only: told after each v147 migration step (V147_STEPS); a throw stops the open there, as a crash would. */
1890
+ migrationStep?: (step: MigrationStep) => void;
1864
1891
  };
1865
1892
  /** Every connection's wait at SQLite's lock boundary, in milliseconds. */
1866
1893
  export declare const CONCURRENT_WRITER_WAIT_MS = 15000;
@@ -2465,6 +2492,7 @@ export declare class Store {
2465
2492
  * generation, revocation — one read, no inference. */
2466
2493
  accountOf(name: string): {
2467
2494
  credentialHash: string;
2495
+ passwordSet: boolean;
2468
2496
  role: "approver" | "viewer";
2469
2497
  generation: number;
2470
2498
  revokedAt: string | null;
@@ -8241,3 +8269,98 @@ export declare function releaseProgressFor(store: Store, taskId: string): Releas
8241
8269
  export declare function latestReleaseStepTask(store: Store): string | null;
8242
8270
  /** Seconds a step or check took (to its end, or to `now` while it runs); null without a start. */
8243
8271
  export declare function elapsedSeconds(startedAt: string | null, endedAt: string | null, now: Date): number | null;
8272
+ /** Local mode as saved, or null when this installation never had it (absent is off). */
8273
+ export declare function localModeOf(store: Store): LocalMode | null;
8274
+ /** Who made this database (installation_fact created-by), or null: every database made before v147, or by `serve`. */
8275
+ export declare function installationCreatedBy(store: Store): string | null;
8276
+ export type InitLocalModeResult = {
8277
+ ok: true;
8278
+ owner: string;
8279
+ generation: number;
8280
+ } | {
8281
+ ok: false;
8282
+ reason: "not-laptop" | "no-fence" | "has-accounts" | "already";
8283
+ };
8284
+ /**
8285
+ * Turn local mode on for a database `up --laptop` made, once (plan F). Only when: that database records created-by
8286
+ * laptop, it has no account yet, it never had local mode, and the caller found a fence on this machine (diagnostic
8287
+ * only: it decides whether local mode is offered). One transaction: the owner (no password: a hash of random bytes
8288
+ * that are thrown away, password_set 0), the local_mode row and one ledger line, each inserted only if nothing is
8289
+ * there. A retry or a racing start inserts nothing and writes no second ledger line. Migration and the update
8290
+ * rehearsal never call this.
8291
+ */
8292
+ export declare function initLocalMode(store: Store, input: {
8293
+ owner: string;
8294
+ fenceAvailable: boolean;
8295
+ now: Date;
8296
+ }): InitLocalModeResult;
8297
+ export type EnrollOwnerResult = {
8298
+ ok: true;
8299
+ generation: number;
8300
+ } | {
8301
+ ok: false;
8302
+ reason: "already-set" | "local-mode-changed" | "not-owner";
8303
+ };
8304
+ /**
8305
+ * The local owner sets their first password (plan C4), in one IMMEDIATE transaction: the account still has none,
8306
+ * local mode is on at the generation the caller's session was bound to, and the account is the active owner. Then the
8307
+ * hash is written with password_set 1 and the account's generation moves, every derived authority from before ends
8308
+ * (credential-rotation: every browser session of the account, local-owner ones included), and the ledger says so.
8309
+ * Local mode stays on. A second, concurrent enrollment finds password_set 1 and gets "already-set" ("A password was
8310
+ * just set — sign in"). `credentialHash` is hashPassword's output; the password itself never reaches the store.
8311
+ */
8312
+ export declare function enrollOwnerPassword(store: Store, owner: string, credentialHash: string, expectedLocalGeneration: number, now: Date): EnrollOwnerResult;
8313
+ export type SetLocalModeOffResult = {
8314
+ ok: true;
8315
+ generation: number;
8316
+ } | {
8317
+ ok: false;
8318
+ reason: "not-on" | "not-allowed";
8319
+ };
8320
+ /**
8321
+ * Turn local mode off (plan C5: one click in Settings for a full session). The caller passes the authority it proved
8322
+ * for this request; only full authority of an active instance operator turns it off. In one transaction: the row reads
8323
+ * off and its generation moves (every local-owner session, link and approval bound to the old one stops standing),
8324
+ * the account's local-owner sessions are deleted, and the ledger says who. Turning it back on is not offered.
8325
+ */
8326
+ export declare function setLocalModeOff(store: Store, by: {
8327
+ name: string;
8328
+ authority: Authority;
8329
+ }, now: Date): SetLocalModeOffResult;
8330
+ /**
8331
+ * What a local sign-in link is bound to, read now (plan C4): local mode on, and its owner an active approver. Null
8332
+ * otherwise. The console's mintLocalLink records this; redemption reads it again and compares every field.
8333
+ */
8334
+ export declare function localOwnerBinding(store: Store): {
8335
+ account: string;
8336
+ localGeneration: number;
8337
+ accountGeneration: number;
8338
+ } | null;
8339
+ /** easy-start D: "Keep Toolroll running in the background?" is asked once per installation; this fact records it. */
8340
+ export declare const BACKGROUND_ASKED_FACT = "background-asked";
8341
+ export type BackgroundAnswer = "yes" | "no";
8342
+ /** The person's one answer to the background question and when it was given, or null when it was never asked. */
8343
+ export declare function backgroundAsked(store: Store): {
8344
+ answer: BackgroundAnswer;
8345
+ at: string;
8346
+ } | null;
8347
+ /** Record the answer, once: the first write wins (installation facts are never updated). True when this call recorded it. */
8348
+ export declare function recordBackgroundAsked(store: Store, answer: BackgroundAnswer, now: Date): boolean;
8349
+ /**
8350
+ * Easy-start C5 provenance: a task a laptop's local owner files (no password, inside its admitted act) says so in its
8351
+ * filing ledger line — `filed via local-owner · local <generation> <browser|terminal>` — so the record shows which local
8352
+ * mode generation and which road it came by. Null for every other filing (a person with a password, the lead, a
8353
+ * machine), which keeps the line as it was. task_ref.filed_via is left to the road it came by (console, mate, cli…).
8354
+ */
8355
+ export declare function localFilingProvenance(filer: string | null): string | null;
8356
+ /**
8357
+ * Easy start: the port a NEW laptop install chose when `up`'s default (4180) was already held by something that isn't
8358
+ * this database's console (another Toolroll, the desktop app, any program). Saved once, the first time `up --laptop`
8359
+ * reserves a port on a database `up --laptop` made, so later starts, the background service's unit and `toolroll open`
8360
+ * all find the same address. Installs made any other way never record one: their port stays `up`'s default.
8361
+ */
8362
+ export declare const LAPTOP_PORT_FACT = "laptop-port";
8363
+ /** The saved laptop port, or null (none saved, or a value that isn't a port). */
8364
+ export declare function laptopPortOf(store: Store): number | null;
8365
+ /** Record the laptop port, once (installation facts are never updated). */
8366
+ export declare function recordLaptopPort(store: Store, port: number, now: Date): void;