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
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Keeping Toolroll running in the background on a laptop (easy-start plan D): `toolroll background on|off|show`,
3
+ * `toolroll stop`, and the `laptop` part of `toolroll status`.
4
+ *
5
+ * The login item itself is laptop-service.ts's definition (com.toolroll.up / toolroll-up.service). This module is
6
+ * the handoff around it:
7
+ *
8
+ * - **on** writes and validates the unit without loading it, stops the foreground `up` this invoker owns (if one runs
9
+ * for this database) by SIGTERM to its controller and waits until that controller's whole process record is proved
10
+ * gone by start identity and the port is free, then loads the unit and proves the console live within 30 s. Any
11
+ * failure boots the unit out, removes it (or puts back the one that was there), starts the foreground `up` again if
12
+ * it was stopped, and says what failed. An identical unit already loaded is left alone.
13
+ * - **off** stops and removes only our own label.
14
+ * - **stop**: with the service loaded, the supervisor stops it (it comes back at login); otherwise the controller of
15
+ * the proven console, found through its signed process record, gets SIGTERM — never the console child, which the
16
+ * controller would only start again.
17
+ *
18
+ * Every outside effect goes through LaptopSeams: tests give a temporary HOME, a scripted launchctl/systemctl, and their
19
+ * own child processes. Without a supervisor seam, a test run (VITEST) refuses to reach the real launchctl or systemctl.
20
+ * Other services (the desktop app's com.toolroll.browser, a watch on this database) are reported, never touched.
21
+ */
22
+ import { execFileSync } from "node:child_process";
23
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
24
+ import { createServer } from "node:net";
25
+ import { homedir, hostname } from "node:os";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { proveLiveConsole } from "./console-instance.js";
29
+ import { writeFileDurably } from "./daemon.js";
30
+ import { compareInstalled, detectOtherServices, laptopServiceDefinition, LAPTOP_LABEL_DARWIN, LAPTOP_LABEL_LINUX } from "./laptop-service.js";
31
+ import { packageVersion } from "./monitoring.js";
32
+ import { underAgent } from "./prompt.js";
33
+ import { processGone, readRunnerProcessRecord, realProcessProbe, recordedHolderProvedGone, recordStandsFor, runnerProcessFile } from "./runner-process.js";
34
+ import { laptopPortOf } from "./store.js";
35
+ function resolveSeams(seams) {
36
+ return {
37
+ platform: seams.platform ?? process.platform,
38
+ home: seams.home ?? homedir(),
39
+ supervisor: seams.supervisor ?? realSupervisor,
40
+ node: seams.node ?? process.execPath,
41
+ dist: seams.dist ?? dirname(fileURLToPath(import.meta.url)),
42
+ ...(seams.pathEnv === undefined ? {} : { pathEnv: seams.pathEnv }),
43
+ terminal: seams.terminal ?? false,
44
+ agent: seams.agent ?? underAgent(process.env),
45
+ confirm: seams.confirm ?? (async () => false),
46
+ proveConsole: seams.proveConsole ?? ((databaseFile, store) => proveLiveConsole(databaseFile, store, packageVersion())),
47
+ probe: seams.probe ?? realProcessProbe,
48
+ parentOf: seams.parentOf ?? parentPid,
49
+ signal: seams.signal ?? ((pid, signal) => { process.kill(pid, signal); }),
50
+ portFree: seams.portFree ?? portIsFree,
51
+ sleep: seams.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))),
52
+ host: seams.host ?? hostname(),
53
+ healthTimeoutMs: seams.healthTimeoutMs ?? 30_000,
54
+ // A drain is bounded by `up`'s own forced stop (stop-grace 30 s + 20 s); a margin past it.
55
+ exitTimeoutMs: seams.exitTimeoutMs ?? 75_000,
56
+ ...(seams.foreground === undefined ? {} : { foreground: seams.foreground }),
57
+ };
58
+ }
59
+ const realSupervisor = async (file, args, options) => {
60
+ // Tests never reach this machine's real launchd or systemd: they pass their own supervisor.
61
+ if (process.env["VITEST"] !== undefined)
62
+ throw Error(`a test reached the real ${file}: pass LaptopSeams.supervisor`);
63
+ return (await import("./exec.js")).run(file, args, options);
64
+ };
65
+ function parentPid(pid) {
66
+ if (!Number.isSafeInteger(pid) || pid <= 1 || process.platform === "win32")
67
+ return null;
68
+ try {
69
+ const raw = execFileSync("/bin/ps", ["-p", String(pid), "-o", "ppid="], { encoding: "utf8", env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, stdio: ["ignore", "pipe", "ignore"], timeout: 2_000 });
70
+ const parent = Number(raw.trim());
71
+ return Number.isSafeInteger(parent) && parent > 0 ? parent : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function portIsFree(port) {
78
+ return new Promise(resolve => {
79
+ const probe = createServer();
80
+ probe.once("error", () => resolve(false));
81
+ probe.listen(port, "127.0.0.1", () => probe.close(() => resolve(true)));
82
+ });
83
+ }
84
+ /** `toolroll up`'s port when none is named. */
85
+ const DEFAULT_PORT = 4180;
86
+ const uid = () => (typeof process.getuid === "function" ? process.getuid() : 501);
87
+ function labelOf(platform) {
88
+ return platform === "darwin" ? LAPTOP_LABEL_DARWIN : LAPTOP_LABEL_LINUX;
89
+ }
90
+ function unitPath(platform, home) {
91
+ return platform === "darwin" ? join(home, "Library", "LaunchAgents", `${LAPTOP_LABEL_DARWIN}.plist`) : join(home, ".config", "systemd", "user", `${LAPTOP_LABEL_LINUX}.service`);
92
+ }
93
+ function readUnit(path) {
94
+ try {
95
+ return readFileSync(path, "utf8");
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function firstLine(text) {
102
+ return text.trim().split("\n")[0] ?? "";
103
+ }
104
+ // ---- the supervisor, for our own label only ------------------------------------------------------------------------
105
+ /** Whether our label is loaded (launchd) or active (systemd) now. */
106
+ async function serviceLoaded(s, platform) {
107
+ if (platform === "darwin")
108
+ return (await s.supervisor("launchctl", ["print", `gui/${uid()}/${LAPTOP_LABEL_DARWIN}`])).code === 0;
109
+ const answer = await s.supervisor("systemctl", ["--user", "is-active", `${LAPTOP_LABEL_LINUX}.service`]);
110
+ return ["active", "activating", "reloading", "deactivating"].includes(answer.stdout.trim());
111
+ }
112
+ /** The written unit, checked by the platform's own validator, never loaded. Null when it passes. */
113
+ async function validateUnit(s, platform, path) {
114
+ if (platform === "darwin") {
115
+ const checked = await s.supervisor("plutil", ["-lint", path]);
116
+ return checked.code === 0 ? null : `plutil refused the login item: ${firstLine(checked.stdout) || firstLine(checked.stderr) || `exit ${checked.code}`}`;
117
+ }
118
+ const checked = await s.supervisor("systemd-analyze", ["verify", "--user", path]);
119
+ // Not every Linux has systemd-analyze; systemctl itself still parses the unit when it loads it.
120
+ if (checked.notFound)
121
+ return null;
122
+ return checked.code === 0 ? null : `systemd-analyze refused the unit: ${firstLine(checked.stderr) || firstLine(checked.stdout) || `exit ${checked.code}`}`;
123
+ }
124
+ /** Load and start our unit. Null on success. */
125
+ async function startService(s, platform, path) {
126
+ if (platform === "darwin") {
127
+ const service = `gui/${uid()}/${LAPTOP_LABEL_DARWIN}`;
128
+ // `background off` disabled the label; loading it again is the person's explicit yes.
129
+ const enabled = await s.supervisor("launchctl", ["enable", service]);
130
+ if (enabled.code !== 0)
131
+ return `launchctl could not enable ${LAPTOP_LABEL_DARWIN}: ${firstLine(enabled.stderr) || `exit ${enabled.code}`}`;
132
+ const loaded = await s.supervisor("launchctl", ["bootstrap", `gui/${uid()}`, path]);
133
+ return loaded.code === 0 ? null : `launchctl could not load ${LAPTOP_LABEL_DARWIN}: ${firstLine(loaded.stderr) || `exit ${loaded.code}`}`;
134
+ }
135
+ const reload = await s.supervisor("systemctl", ["--user", "daemon-reload"]);
136
+ if (reload.code !== 0)
137
+ return `systemctl daemon-reload failed: ${firstLine(reload.stderr) || `exit ${reload.code}`}`;
138
+ const enabled = await s.supervisor("systemctl", ["--user", "enable", "--now", `${LAPTOP_LABEL_LINUX}.service`]);
139
+ return enabled.code === 0 ? null : `systemctl could not start ${LAPTOP_LABEL_LINUX}: ${firstLine(enabled.stderr) || `exit ${enabled.code}`}`;
140
+ }
141
+ /**
142
+ * Stop our unit. `until: "login"` (toolroll stop) leaves it to start again at the next login; `"removed"` (off,
143
+ * rollback) also disables it. Resolves once the supervisor no longer has it running. Null on success.
144
+ */
145
+ async function stopService(s, platform, path, until) {
146
+ if (platform === "darwin") {
147
+ const service = `gui/${uid()}/${LAPTOP_LABEL_DARWIN}`;
148
+ if (until === "removed") {
149
+ const disabled = await s.supervisor("launchctl", ["disable", service]);
150
+ if (disabled.code !== 0)
151
+ return `launchctl could not disable ${LAPTOP_LABEL_DARWIN}: ${firstLine(disabled.stderr) || `exit ${disabled.code}`}`;
152
+ }
153
+ const out = await s.supervisor("launchctl", ["bootout", service]);
154
+ if (out.code !== 0)
155
+ await s.supervisor("launchctl", ["unload", path]);
156
+ // launchctl can return before the label leaves the domain; wait for the observable boundary.
157
+ for (const deadline = Date.now() + s.exitTimeoutMs;;) {
158
+ if ((await s.supervisor("launchctl", ["print", service])).code !== 0)
159
+ return null;
160
+ if (Date.now() >= deadline)
161
+ return `launchctl did not stop ${LAPTOP_LABEL_DARWIN}; it is still loaded`;
162
+ await s.sleep(100);
163
+ }
164
+ }
165
+ const unit = `${LAPTOP_LABEL_LINUX}.service`;
166
+ const stopped = await s.supervisor("systemctl", until === "removed" ? ["--user", "disable", "--now", unit] : ["--user", "stop", unit]);
167
+ if (stopped.code !== 0)
168
+ return `systemctl could not stop ${LAPTOP_LABEL_LINUX}: ${firstLine(stopped.stderr) || `exit ${stopped.code}`}`;
169
+ return null;
170
+ }
171
+ /**
172
+ * The controller of this database's live console, proved from the plane's own records: a live runner on this host
173
+ * whose signed process record names the proven console (pid and start time) and exactly one live `up` process, that
174
+ * `up` still the process the record names (same start time), and the console's parent that `up`. Anything else —
175
+ * no record, a reused pid, a console whose parent is someone else — proves nothing and is refused.
176
+ */
177
+ export function findController(store, databaseFile, console_, s) {
178
+ let why = "no Toolroll process record names the running console";
179
+ for (const runner of store.listRunners()) {
180
+ if (runner.retiredAt !== null || runner.host !== s.host)
181
+ continue;
182
+ const holder = store.getRunner(runner.name);
183
+ if (holder === null)
184
+ continue;
185
+ let file;
186
+ try {
187
+ file = runnerProcessFile(databaseFile, runner.name);
188
+ }
189
+ catch {
190
+ continue;
191
+ }
192
+ const record = readRunnerProcessRecord(file);
193
+ if (recordStandsFor(record, holder, s.host) !== null || record === null)
194
+ continue;
195
+ const consoleEntry = record.processes.find(one => one.role === "console" && one.pid === console_.pid);
196
+ if (consoleEntry === undefined)
197
+ continue;
198
+ if (s.probe.start(console_.pid) !== consoleEntry.started)
199
+ return { why: `process ${console_.pid} is not the console its record names (it started at another time)` };
200
+ const ups = record.processes.filter(one => one.role === "up" && !processGone(one, s.probe));
201
+ if (ups.length !== 1) {
202
+ why = ups.length === 0 ? "the process that started the console is no longer running" : "the console's record names more than one running `up`";
203
+ continue;
204
+ }
205
+ const up = ups[0];
206
+ if (s.probe.start(up.pid) !== up.started)
207
+ return { why: `process ${up.pid} is not the \`up\` its record names (the id was reused)` };
208
+ if (s.parentOf(console_.pid) !== up.pid)
209
+ return { why: `the console (process ${console_.pid}) was not started by process ${up.pid}` };
210
+ return { pid: up.pid, started: up.started, consolePid: console_.pid, port: console_.port, url: console_.url, runner: runner.name, holder };
211
+ }
212
+ return { why };
213
+ }
214
+ /**
215
+ * SIGTERM to the controller (its full drain), re-checking its start time at the moment of the signal, then wait until
216
+ * every process in its signed record is proved gone by start identity. Never signals the console child.
217
+ */
218
+ async function drainController(databaseFile, controller, s) {
219
+ if (s.probe.start(controller.pid) !== controller.started)
220
+ return `process ${controller.pid} is no longer the \`up\` that was found; nothing was stopped`;
221
+ try {
222
+ s.signal(controller.pid, "SIGTERM");
223
+ }
224
+ catch (error) {
225
+ return `could not signal process ${controller.pid}: ${error.message}`;
226
+ }
227
+ const file = runnerProcessFile(databaseFile, controller.runner);
228
+ let last = "";
229
+ for (const deadline = Date.now() + s.exitTimeoutMs;;) {
230
+ const proof = recordedHolderProvedGone(file, controller.holder, s.host, s.probe);
231
+ if (proof.gone)
232
+ return null;
233
+ last = proof.why;
234
+ if (Date.now() >= deadline)
235
+ return `Toolroll (process ${controller.pid}) did not finish stopping: ${last}`;
236
+ await s.sleep(250);
237
+ }
238
+ }
239
+ async function waitPortFree(port, s) {
240
+ for (const deadline = Date.now() + s.exitTimeoutMs;;) {
241
+ if (await s.portFree(port))
242
+ return true;
243
+ if (Date.now() >= deadline)
244
+ return false;
245
+ await s.sleep(250);
246
+ }
247
+ }
248
+ async function awaitHealthy(databaseFile, store, s) {
249
+ let proof = { ok: false, state: "none", message: "no console is running" };
250
+ for (const deadline = Date.now() + s.healthTimeoutMs;;) {
251
+ proof = await s.proveConsole(databaseFile, store);
252
+ if (proof.ok || Date.now() >= deadline)
253
+ return proof;
254
+ await s.sleep(500);
255
+ }
256
+ }
257
+ function definitionFor(s, platform, databaseFile, port) {
258
+ return laptopServiceDefinition({ platform, home: s.home, node: s.node, dist: s.dist, db: databaseFile, port, ...(s.pathEnv === undefined ? {} : { pathEnv: s.pathEnv }) });
259
+ }
260
+ export async function backgroundShow(store, databaseFile, requestedPort, seams = {}) {
261
+ const s = resolveSeams(seams);
262
+ const platform = s.platform;
263
+ const live = await s.proveConsole(databaseFile, store);
264
+ const running = live.ok ? { url: live.url, pid: live.pid } : null;
265
+ const port = requestedPort ?? (live.ok ? live.port : laptopPortOf(store) ?? DEFAULT_PORT);
266
+ if (platform !== "darwin" && platform !== "linux")
267
+ return { ok: true, show: { platform, label: null, unit: null, installed: "absent", loaded: false, running, conflicts: [], port } };
268
+ const definition = definitionFor(s, platform, databaseFile, port);
269
+ if ("error" in definition)
270
+ return { ok: false, reason: "unsupported", message: definition.error };
271
+ const conflicts = detectOtherServices({ platform, home: s.home, db: databaseFile });
272
+ if (!Array.isArray(conflicts))
273
+ return { ok: false, reason: "unreadable", message: conflicts.error };
274
+ return { ok: true, show: { platform, label: definition.label, unit: definition.path, installed: compareInstalled(readUnit(definition.path), definition), loaded: await serviceLoaded(s, platform), running, conflicts, port } };
275
+ }
276
+ /** `requestedPort`: --port, else the running console's, else the port this laptop install saved, else `up`'s default. */
277
+ export async function backgroundOn(store, databaseFile, requestedPort, seams = {}) {
278
+ const s = resolveSeams(seams);
279
+ const platform = s.platform;
280
+ if (platform !== "darwin" && platform !== "linux")
281
+ return { ok: false, reason: "unsupported", message: `there is no background service for ${platform}; keep \`toolroll up\` running yourself` };
282
+ // A login item runs at every login: something a person sets up, never a coding agent on their behalf.
283
+ if (s.agent)
284
+ return { ok: false, reason: "agent", message: "a coding agent can't set Toolroll to start at login; run `toolroll background on` yourself at a terminal" };
285
+ const first = await s.proveConsole(databaseFile, store);
286
+ const port = requestedPort ?? (first.ok ? first.port : laptopPortOf(store) ?? DEFAULT_PORT);
287
+ const definition = definitionFor(s, platform, databaseFile, port);
288
+ if ("error" in definition)
289
+ return { ok: false, reason: "unsupported", message: definition.error };
290
+ if (!existsSync(definition.command[1]))
291
+ return { ok: false, reason: "unsupported", message: `${definition.command[1]} does not exist: run \`toolroll background on\` from an installed Toolroll` };
292
+ const before = readUnit(definition.path);
293
+ const installed = compareInstalled(before, definition);
294
+ const wasLoaded = await serviceLoaded(s, platform);
295
+ if (installed === "same" && wasLoaded) {
296
+ const live = first.ok ? first : await s.proveConsole(databaseFile, store);
297
+ if (live.ok)
298
+ return { ok: true, action: "unchanged", label: definition.label, unit: definition.path, url: live.url, pid: live.pid, stoppedForeground: false };
299
+ }
300
+ // 1. Other services on this database (or the desktop app's console) are a person's call, at a terminal.
301
+ const conflicts = detectOtherServices({ platform, home: s.home, db: databaseFile });
302
+ if (!Array.isArray(conflicts))
303
+ return { ok: false, reason: "unreadable", message: conflicts.error };
304
+ if (conflicts.length > 0) {
305
+ const reasons = conflicts.map(one => one.reason).join("; ");
306
+ if (!s.terminal)
307
+ return { ok: false, reason: "conflict", message: `${reasons}. Running both could start two Toolrolls; run \`toolroll background on\` at a terminal to confirm` };
308
+ if (!(await s.confirm(`${reasons}. Keep Toolroll running in the background anyway?`)))
309
+ return { ok: false, reason: "declined", message: "Nothing was changed." };
310
+ }
311
+ // 2. What runs now: our service under another definition (replaced), the foreground `up` this invoker owns, or
312
+ // nothing. A console that runs elsewhere is not ours to stop.
313
+ const live = await s.proveConsole(databaseFile, store);
314
+ if (!live.ok && live.state === "refused")
315
+ return { ok: false, reason: "console-unproven", message: live.message };
316
+ let foreground = null;
317
+ let controller = null;
318
+ if (live.ok) {
319
+ const found = findController(store, databaseFile, live, s);
320
+ if ("why" in found)
321
+ return { ok: false, reason: "console-unproven", message: `Toolroll is running (${live.url}), but ${found.why}. Nothing was changed` };
322
+ controller = found;
323
+ if (!wasLoaded) {
324
+ if (s.foreground === undefined || s.foreground.controllerPid !== found.pid) {
325
+ return { ok: false, reason: "running-elsewhere", message: `Toolroll is already running in another terminal (${live.url}, process ${found.pid}). Stop it with \`toolroll stop\`, then run \`toolroll background on\`` };
326
+ }
327
+ foreground = found;
328
+ }
329
+ }
330
+ // 3. The unit, written and validated, not loaded.
331
+ const rollback = [];
332
+ const restoreUnit = () => {
333
+ try {
334
+ if (before === null)
335
+ rmSync(definition.path, { force: true });
336
+ else
337
+ writeFileDurably(definition.path, before, 0o644);
338
+ }
339
+ catch {
340
+ rollback.push(`could not put ${definition.path} back`);
341
+ }
342
+ };
343
+ try {
344
+ mkdirSync(dirname(definition.path), { recursive: true });
345
+ mkdirSync(dirname(definition.logPath), { recursive: true });
346
+ if (installed !== "same")
347
+ writeFileDurably(definition.path, definition.text, 0o644);
348
+ }
349
+ catch (error) {
350
+ restoreUnit();
351
+ return { ok: false, reason: "write", message: `could not write ${definition.path}: ${error.message}` };
352
+ }
353
+ /** 7. Undo everything this call did, in reverse, and say what failed. */
354
+ const fail = async (message, reason, loaded = false) => {
355
+ if (loaded || (await serviceLoaded(s, platform))) {
356
+ const out = await stopService(s, platform, definition.path, "removed");
357
+ if (out !== null)
358
+ rollback.push(out);
359
+ }
360
+ restoreUnit();
361
+ if (wasLoaded)
362
+ await restartPrevious(s, platform, definition.path, before, rollback);
363
+ if (foreground !== null && s.foreground !== undefined) {
364
+ try {
365
+ await s.foreground.restart();
366
+ rollback.push("started Toolroll in this terminal again");
367
+ }
368
+ catch (error) {
369
+ rollback.push(`could not start Toolroll in this terminal again: ${error.message}`);
370
+ }
371
+ }
372
+ return { ok: false, reason, message: `${message}. The background service was removed`, rolledBack: rollback };
373
+ };
374
+ const invalid = await validateUnit(s, platform, definition.path);
375
+ if (invalid !== null) {
376
+ restoreUnit();
377
+ return { ok: false, reason: "invalid-unit", message: `${invalid}. Nothing was changed` };
378
+ }
379
+ // 4. Hand over: the old definition's service is stopped through the supervisor, the foreground `up` by SIGTERM to its
380
+ // controller; either way, every process its record names is proved gone and the port is free before anything loads.
381
+ if (wasLoaded) {
382
+ const stopped = await stopService(s, platform, definition.path, "login");
383
+ if (stopped !== null) {
384
+ restoreUnit();
385
+ return { ok: false, reason: "supervisor", message: `${stopped}. Nothing was changed` };
386
+ }
387
+ }
388
+ if (controller !== null) {
389
+ const drained = wasLoaded ? await waitControllerGone(databaseFile, controller, s) : await drainController(databaseFile, controller, s);
390
+ if (drained !== null) {
391
+ restoreUnit();
392
+ if (wasLoaded)
393
+ await restartPrevious(s, platform, definition.path, before, rollback);
394
+ return { ok: false, reason: "still-running", message: drained, ...(rollback.length === 0 ? {} : { rolledBack: rollback }) };
395
+ }
396
+ }
397
+ const handoffPort = controller?.port ?? port;
398
+ if (!(await waitPortFree(handoffPort, s)) || (handoffPort !== port && !(await waitPortFree(port, s)))) {
399
+ return fail(`port ${port} is still taken after Toolroll stopped`, "port-busy");
400
+ }
401
+ // 5. Load it, and 6. prove the console it starts.
402
+ const started = await startService(s, platform, definition.path);
403
+ if (started !== null)
404
+ return fail(started, "supervisor");
405
+ const healthy = await awaitHealthy(databaseFile, store, s);
406
+ if (!healthy.ok)
407
+ return fail(`the background service did not answer within ${Math.round(s.healthTimeoutMs / 1000)} s (${healthy.message})`, "unhealthy", true);
408
+ return { ok: true, action: installed === "absent" ? "installed" : wasLoaded ? "replaced" : "installed", label: definition.label, unit: definition.path, url: healthy.url, pid: healthy.pid, stoppedForeground: foreground !== null };
409
+ }
410
+ /** The previous service, put back as it was: its unit is restored by the caller; this loads it again. */
411
+ async function restartPrevious(s, platform, path, before, rollback) {
412
+ if (before === null)
413
+ return;
414
+ const again = await startService(s, platform, path);
415
+ rollback.push(again === null ? "started the previous background service again" : `could not start the previous background service again: ${again}`);
416
+ }
417
+ /** The supervisor stopped it: wait for the record's proof, without signalling anything ourselves. */
418
+ async function waitControllerGone(databaseFile, controller, s) {
419
+ const file = runnerProcessFile(databaseFile, controller.runner);
420
+ for (const deadline = Date.now() + s.exitTimeoutMs;;) {
421
+ const proof = recordedHolderProvedGone(file, controller.holder, s.host, s.probe);
422
+ if (proof.gone)
423
+ return null;
424
+ if (Date.now() >= deadline)
425
+ return `Toolroll (process ${controller.pid}) did not finish stopping: ${proof.why}`;
426
+ await s.sleep(250);
427
+ }
428
+ }
429
+ export async function backgroundOff(store, databaseFile, seams = {}) {
430
+ const s = resolveSeams(seams);
431
+ const platform = s.platform;
432
+ if (platform !== "darwin" && platform !== "linux")
433
+ return { ok: false, reason: "unsupported", message: `there is no background service for ${platform}` };
434
+ const label = labelOf(platform), path = unitPath(platform, s.home);
435
+ const present = existsSync(path);
436
+ const loaded = await serviceLoaded(s, platform);
437
+ if (!present && !loaded)
438
+ return { ok: true, action: "already-off", label, unit: path };
439
+ let controller = null;
440
+ if (loaded) {
441
+ const live = await s.proveConsole(databaseFile, store);
442
+ if (live.ok) {
443
+ const found = findController(store, databaseFile, live, s);
444
+ if (!("why" in found))
445
+ controller = found;
446
+ }
447
+ const stopped = await stopService(s, platform, path, "removed");
448
+ if (stopped !== null)
449
+ return { ok: false, reason: "supervisor", message: stopped };
450
+ if (controller !== null) {
451
+ const gone = await waitControllerGone(databaseFile, controller, s);
452
+ if (gone !== null)
453
+ return { ok: false, reason: "still-running", message: gone };
454
+ }
455
+ }
456
+ try {
457
+ rmSync(path, { force: true });
458
+ }
459
+ catch (error) {
460
+ return { ok: false, reason: "write", message: `could not remove ${path}: ${error.message}` };
461
+ }
462
+ if (platform === "linux")
463
+ await s.supervisor("systemctl", ["--user", "daemon-reload"]);
464
+ return { ok: true, action: "removed", label, unit: path };
465
+ }
466
+ export async function laptopStop(store, databaseFile, seams = {}) {
467
+ const s = resolveSeams(seams);
468
+ const platform = s.platform;
469
+ const live = await s.proveConsole(databaseFile, store);
470
+ if (!live.ok && live.state === "refused")
471
+ return { ok: false, reason: "console-unproven", message: live.message };
472
+ const found = live.ok ? findController(store, databaseFile, live, s) : null;
473
+ // Our service, loaded: the supervisor stops it (SIGTERM reaches the controller, which drains). It starts at login.
474
+ if ((platform === "darwin" || platform === "linux") && existsSync(unitPath(platform, s.home)) && await serviceLoaded(s, platform)) {
475
+ const stopped = await stopService(s, platform, unitPath(platform, s.home), "login");
476
+ if (stopped !== null)
477
+ return { ok: false, reason: "supervisor", message: stopped };
478
+ if (found !== null && !("why" in found)) {
479
+ const gone = await waitControllerGone(databaseFile, found, s);
480
+ if (gone !== null)
481
+ return { ok: false, reason: "still-running", message: gone };
482
+ }
483
+ return { ok: true, action: "service-stopped", pid: found !== null && !("why" in found) ? found.pid : null, startsAtLogin: true };
484
+ }
485
+ if (!live.ok)
486
+ return { ok: true, action: "not-running", pid: null, startsAtLogin: false };
487
+ if (found === null || "why" in found)
488
+ return { ok: false, reason: "console-unproven", message: `Toolroll is running (${live.url}), but ${found?.why ?? "its controller can't be found"}. Nothing was stopped` };
489
+ const drained = await drainController(databaseFile, found, s);
490
+ if (drained !== null)
491
+ return { ok: false, reason: "still-running", message: drained };
492
+ return { ok: true, action: "stopped", pid: found.pid, startsAtLogin: false };
493
+ }
494
+ /**
495
+ * The `laptop` key of `toolroll status`: whether this database's console is proven live (its address and the console
496
+ * process), and whether a background service runs it — ours ("on"), another service on this database or the desktop
497
+ * app's ("other-service"), or none ("off"). Reads files only; never asks the supervisor.
498
+ */
499
+ export async function laptopStatus(store, databaseFile, seams = {}) {
500
+ const s = resolveSeams(seams);
501
+ let live;
502
+ try {
503
+ live = await s.proveConsole(databaseFile, store);
504
+ }
505
+ catch {
506
+ live = { ok: false, state: "none", message: "" };
507
+ }
508
+ let background = "off";
509
+ if (s.platform === "darwin" || s.platform === "linux") {
510
+ if (existsSync(unitPath(s.platform, s.home)))
511
+ background = "on";
512
+ else {
513
+ const others = detectOtherServices({ platform: s.platform, home: s.home, db: databaseFile });
514
+ if (!Array.isArray(others) || others.length > 0)
515
+ background = "other-service";
516
+ }
517
+ }
518
+ return { running: live.ok, url: live.ok ? live.url : null, pid: live.ok ? live.pid : null, background };
519
+ }
520
+ // ---- easy start: starting the installed service again ------------------------------------------------------------------
521
+ /** Whether Toolroll's own login item is installed here (its unit file exists). Reads one path; asks no supervisor. */
522
+ export function backgroundInstalled(seams = {}) {
523
+ const s = resolveSeams(seams);
524
+ return (s.platform === "darwin" || s.platform === "linux") && existsSync(unitPath(s.platform, s.home));
525
+ }
526
+ /**
527
+ * Bare `toolroll` and `toolroll open` with the background service installed but this database's console not running
528
+ * (stopped with `toolroll stop`, or not yet started at this login): start it through its supervisor — never a second
529
+ * `up` beside it — and prove the console it runs live. Our own label only.
530
+ */
531
+ export async function startInstalledService(store, databaseFile, seams = {}) {
532
+ const s = resolveSeams(seams);
533
+ const platform = s.platform;
534
+ if ((platform !== "darwin" && platform !== "linux") || !existsSync(unitPath(platform, s.home)))
535
+ return { ok: false, state: "none", message: "the background service isn't installed" };
536
+ const already = await s.proveConsole(databaseFile, store);
537
+ if (already.ok || already.state === "refused")
538
+ return already;
539
+ if (!(await serviceLoaded(s, platform))) {
540
+ const started = await startService(s, platform, unitPath(platform, s.home));
541
+ if (started !== null)
542
+ return { ok: false, state: "refused", message: started };
543
+ }
544
+ else if (platform === "darwin") {
545
+ // Loaded but not answering (between restarts): ask launchd to run it now.
546
+ await s.supervisor("launchctl", ["kickstart", `gui/${uid()}/${LAPTOP_LABEL_DARWIN}`]);
547
+ }
548
+ const healthy = await awaitHealthy(databaseFile, store, s);
549
+ return healthy.ok ? healthy : { ok: false, state: "refused", message: `the background service did not answer within ${Math.round(s.healthTimeoutMs / 1000)} s (${healthy.message})` };
550
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The laptop-mode login item, generated and compared — never installed.
3
+ *
4
+ * Laptop mode keeps one `toolroll up --laptop` console running for the
5
+ * person who is logged in: a launchd LaunchAgent on macOS
6
+ * (~/Library/LaunchAgents/com.toolroll.up.plist), a systemd user unit on
7
+ * Linux (~/.config/systemd/user/toolroll-up.service). This module only
8
+ * computes what that definition would be, whether an installed one matches,
9
+ * and which existing services would conflict with it. Nothing here writes a
10
+ * unit, loads one, or calls launchctl or systemctl; the handoff that does
11
+ * (`toolroll background on`) is laptop-background.ts.
12
+ *
13
+ * The command is pinned with absolute paths, as daemon.ts pins its services:
14
+ * launchd and systemd do not inherit an interactive shell's PATH, so the unit
15
+ * runs the absolute Node binary with the package entry, and the database is
16
+ * the canonical path (realpath of its folder) so a symlinked folder and its
17
+ * target are one database. Values with control characters are refused: a
18
+ * newline would end a systemd line and a NUL cannot appear in either format.
19
+ *
20
+ * Conflicts are reported, never touched: the desktop/browser service
21
+ * (com.toolroll.browser, and com.standing-orders.browser from before the
22
+ * rename) and any `daemon install` watch whose unit points at the same
23
+ * database. Whatever cannot be read counts as a conflict, so the caller asks
24
+ * instead of assuming.
25
+ */
26
+ export declare const LAPTOP_LABEL_DARWIN = "com.toolroll.up";
27
+ export declare const LAPTOP_LABEL_LINUX = "toolroll-up";
28
+ /** The services laptop mode never runs beside on macOS: the desktop app's browser controller, either name. */
29
+ export declare const BROWSER_SERVICE_LABELS: readonly ["com.toolroll.browser", "com.standing-orders.browser"];
30
+ export type LaptopServiceDefinition = {
31
+ platform: "darwin" | "linux";
32
+ label: string;
33
+ /** Where the unit file goes. */
34
+ path: string;
35
+ /** The unit file's exact text. */
36
+ text: string;
37
+ /** definitionDigest(text): equal digests mean installing again is a no-op. */
38
+ digest: string;
39
+ /** The pinned command, argument by argument. */
40
+ command: string[];
41
+ /** The canonical database path baked into the command. */
42
+ db: string;
43
+ logPath: string;
44
+ };
45
+ /** Seam over the few filesystem reads this module makes; tests pass a temp HOME instead of the real one. */
46
+ export type LaptopServiceFs = {
47
+ exists: (path: string) => boolean;
48
+ readFile: (path: string) => string;
49
+ readdir: (path: string) => string[];
50
+ realpath: (path: string) => string;
51
+ };
52
+ export declare const nodeFs: LaptopServiceFs;
53
+ /**
54
+ * The database path the unit names: its folder resolved through realpath,
55
+ * its file name kept (the file may not exist yet). A relative, empty or
56
+ * control-character path, or a folder that cannot be resolved, refuses.
57
+ */
58
+ export declare function canonicalDatabasePath(db: string, fs?: Pick<LaptopServiceFs, "realpath">): string | {
59
+ error: string;
60
+ };
61
+ /**
62
+ * The login item for laptop mode, computed without writing anything. On
63
+ * macOS the text comes from daemon.ts's launchdPlist, so it carries the same
64
+ * KeepAlive/ExitTimeOut contract and the same service digest (which binds the
65
+ * runtime's and package's code, read only). On Linux it is a systemd user
66
+ * unit in daemon.ts's shape, with systemd's `%` and `$` expansion escaped.
67
+ */
68
+ export declare function laptopServiceDefinition(args: {
69
+ platform: NodeJS.Platform;
70
+ home: string;
71
+ /** The absolute Node binary the unit runs. */
72
+ node: string;
73
+ /** The absolute package `dist` folder; the unit runs `<dist>/bin.js`. */
74
+ dist: string;
75
+ db: string;
76
+ port: number;
77
+ /** The executable search path pinned into the unit (daemon.ts's approach); defaults to this process's PATH. */
78
+ pathEnv?: string;
79
+ fs?: Pick<LaptopServiceFs, "realpath">;
80
+ }): LaptopServiceDefinition | {
81
+ error: string;
82
+ };
83
+ /** Whether an installed unit's text is exactly this definition. Null (or undefined) text means no unit is installed. */
84
+ export declare function compareInstalled(existingText: string | null | undefined, definition: Pick<LaptopServiceDefinition, "text" | "digest">): "same" | "different" | "absent";
85
+ export type LaptopConflict = {
86
+ kind: "browser" | "watch";
87
+ label: string;
88
+ path: string;
89
+ /** Plain words for the person who will be asked. */
90
+ reason: string;
91
+ };
92
+ /**
93
+ * Existing services laptop mode must not run beside: the browser service on
94
+ * macOS (whatever database it uses — it is a console of its own), and any
95
+ * daemon.ts watch whose unit points at this database, by `--db` or by its
96
+ * `--token-file` sitting in the database's folder (daemon install keeps the
97
+ * runner token beside the database it was installed from). A unit that
98
+ * cannot be read is listed too. Laptop mode's own unit is never a conflict.
99
+ */
100
+ export declare function detectOtherServices(args: {
101
+ platform: NodeJS.Platform;
102
+ home: string;
103
+ db: string;
104
+ fs?: LaptopServiceFs;
105
+ }): LaptopConflict[] | {
106
+ error: string;
107
+ };
108
+ /** The command an installed unit runs, argument by argument (a plist's ProgramArguments, a unit's ExecStart). */
109
+ export declare function unitCommand(text: string, platform: "darwin" | "linux"): string[];
110
+ /** The value after `flag` in an installed unit's command, or null. */
111
+ export declare function unitFlag(text: string, platform: "darwin" | "linux", flag: string): string | null;