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
@@ -26,7 +26,7 @@ import { inReadOnlyRequest } from "./read-only-request.js";
26
26
  import { createHash, randomUUID, verify as signatureValid, X509Certificate } from "node:crypto";
27
27
  import { spawnSync } from "node:child_process";
28
28
  import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statfsSync, statSync, symlinkSync, writeFileSync } from "node:fs";
29
- import { homedir, userInfo } from "node:os";
29
+ import { homedir, hostname, userInfo } from "node:os";
30
30
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
31
31
  import { fileURLToPath, pathToFileURL } from "node:url";
32
32
  import { createRequire } from "node:module";
@@ -36,8 +36,10 @@ import { processMayBeAlive } from "./process-liveness.js";
36
36
  import { removeTerminalUpdateGate } from "./terminal-catalog.js";
37
37
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
38
38
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
39
+ import { LAPTOP_LABEL_DARWIN, LAPTOP_LABEL_LINUX, unitFlag } from "./laptop-service.js";
40
+ import { processGone, readRunnerProcessRecord, realProcessProbe, recordStandsFor, RUNNER_PROCESS_DIR } from "./runner-process.js";
39
41
  import { NAME } from "./names.js";
40
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS } from "./store.js";
42
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS } from "./store.js";
41
43
  import { isNewer, REGISTRY } from "./releases.js";
42
44
  import { markNeverIndex } from "./never-index.js";
43
45
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -409,7 +411,9 @@ const HISTORY_RULES = {
409
411
  // v126: a receipt's state, acceptance time and held item, a trigger's credential generation and turned-away count,
410
412
  // and who made a card read none, 0, or none. A table with an earlier rule (flow_trigger's v115 move) keeps it too.
411
413
  // v128: a lead message's pending flag reads waiting exactly for a team queue's unadmitted operator message.
412
- ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS))
414
+ // v147: an account's password flag reads set (1), a browser session reads full with no local binding or key, and the
415
+ // console record reads no key epoch.
416
+ ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS))
413
417
  .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
414
418
  };
415
419
  /** The tables a migration may remove whole (v115's removed features). */
@@ -1541,7 +1545,7 @@ export function markWhatsNewSeen(stateDir) {
1541
1545
  durableJson(join(j.stageDir, "update.json"), j);
1542
1546
  }
1543
1547
  const exec = (command, args, options = {}) => spawnSync(command, args, { encoding: "utf8", timeout: options.timeout ?? 300_000, maxBuffer: 16 * 1024 * 1024, ...(options.cwd ? { cwd: options.cwd } : {}), ...(options.input !== undefined ? { input: options.input } : {}) });
1544
- const LABELS = ["com.toolroll.browser", "com.standing-orders.browser"];
1548
+ const LABELS = ["com.toolroll.browser", "com.standing-orders.browser", LAPTOP_LABEL_DARWIN];
1545
1549
  export function currentRuntime(version) {
1546
1550
  return { version, dist: dirname(fileURLToPath(import.meta.url)) };
1547
1551
  }
@@ -1568,6 +1572,9 @@ require(input.verifier).verify(input.bundle, { certificateIssuer: input.issuer,
1568
1572
  export function machineSystem(home = homedir(), env = process.env, seams = {}) {
1569
1573
  const sh = seams.exec ?? exec;
1570
1574
  const supervise = seams.run ?? (async (file, args, options) => (await import("./exec.js")).run(file, args, options));
1575
+ // Linux: the laptop service is a systemd user unit; nothing else here runs under systemd (watches use launchd's road).
1576
+ if ((seams.platform ?? process.platform) === "linux")
1577
+ return { ...machineSystem(home, env, { ...seams, platform: "darwin" }), ...systemdLaptopService(home, sh, supervise, seams) };
1571
1578
  const unitFor = (from) => {
1572
1579
  for (const label of LABELS) {
1573
1580
  const unit = join(home, "Library", "LaunchAgents", `${label}.plist`);
@@ -1807,3 +1814,143 @@ export function retireUpdateJob(id, home = homedir()) {
1807
1814
  }
1808
1815
  catch { /* none */ }
1809
1816
  }
1817
+ // ---- the laptop service under systemd (easy-start D) --------------------------------------------------------------
1818
+ /**
1819
+ * The parts of the update's machine that differ for toolroll-up.service, the laptop service on Linux: finding the
1820
+ * unit, which processes it runs, stopping it and proving its controller exited through the controller's own signed
1821
+ * process record (by start identity, never a pid alone), starting it again, and the health check (the unit active,
1822
+ * running the new runtime, and its console proved live at the new version). Every outside effect is `supervise`
1823
+ * (systemctl), `sh` (ps) or a seam, so a test scripts all of it.
1824
+ */
1825
+ function systemdLaptopService(home, sh, supervise, seams) {
1826
+ const unitPath = join(home, ".config", "systemd", "user", `${LAPTOP_LABEL_LINUX}.service`);
1827
+ const name = `${LAPTOP_LABEL_LINUX}.service`;
1828
+ const probe = seams.probe ?? realProcessProbe;
1829
+ const host = seams.host ?? hostname();
1830
+ const mainPid = async () => {
1831
+ const shown = await supervise("systemctl", ["--user", "show", "--property=MainPID", name]);
1832
+ const pid = Number(/MainPID=(\d+)/.exec(shown.stdout)?.[1] ?? 0);
1833
+ return shown.code === 0 && pid > 1 ? pid : null;
1834
+ };
1835
+ const active = async () => (await supervise("systemctl", ["--user", "is-active", name])).stdout.trim() === "active";
1836
+ /** The processes the controller's signed record names, when that record is authentic for its registration and its
1837
+ * `up` is the unit's main process by start identity. Null: no such proof (the stop then waits on the pid alone). */
1838
+ const recorded = (unit, pid) => {
1839
+ const db = unitFlag(readFileSync(unit, "utf8"), "linux", "--db");
1840
+ if (db === null || !isAbsolute(db))
1841
+ return null;
1842
+ let names;
1843
+ const folder = join(dirname(db), RUNNER_PROCESS_DIR);
1844
+ try {
1845
+ names = readdirSync(folder).filter(one => one.endsWith(".json"));
1846
+ }
1847
+ catch {
1848
+ return null;
1849
+ }
1850
+ let handle = null;
1851
+ try {
1852
+ handle = new (sqlite().DatabaseSync)(db, { readOnly: true });
1853
+ for (const file of names) {
1854
+ const record = readRunnerProcessRecord(join(folder, file));
1855
+ const up = record?.processes.find(one => one.role === "up" && one.pid === pid);
1856
+ if (record === null || up === undefined)
1857
+ continue;
1858
+ const row = handle.prepare("SELECT name, host, registered_at, credential_hash FROM runner WHERE name = ?").get(record.runner);
1859
+ if (row === undefined)
1860
+ continue;
1861
+ const holder = { runner: { name: String(row["name"]), host: String(row["host"]), registeredAt: String(row["registered_at"]) }, credentialHash: String(row["credential_hash"]) };
1862
+ if (recordStandsFor(record, holder, host) !== null || probe.start(pid) !== up.started)
1863
+ continue;
1864
+ return record.processes;
1865
+ }
1866
+ }
1867
+ catch {
1868
+ return null;
1869
+ }
1870
+ finally {
1871
+ handle?.close();
1872
+ }
1873
+ return null;
1874
+ };
1875
+ return {
1876
+ serviceUnit: from => { try {
1877
+ return readFileSync(unitPath, "utf8").includes(from.dist) ? unitPath : null;
1878
+ }
1879
+ catch {
1880
+ return null;
1881
+ } },
1882
+ // `daemon install` watches keep launchd's road; on Linux the update switches only the laptop service.
1883
+ watchUnits: () => [],
1884
+ serviceLoaded: async () => active(),
1885
+ servicePids: async () => {
1886
+ const pid = await mainPid();
1887
+ if (pid === null)
1888
+ return [];
1889
+ const children = await supervise("pgrep", ["-P", String(pid)]);
1890
+ return [pid, ...(children.code === 0 ? children.stdout.trim().split(/\s+/).map(Number).filter(one => Number.isSafeInteger(one) && one > 1) : [])];
1891
+ },
1892
+ stopService: async (unit) => {
1893
+ const pid = await mainPid();
1894
+ const processes = pid === null ? null : recorded(unit, pid);
1895
+ const stopped = await supervise("systemctl", ["--user", "stop", name]);
1896
+ if (stopped.code !== 0)
1897
+ throw Error(`systemctl could not stop ${LAPTOP_LABEL_LINUX}: ${(stopped.stderr || `exit ${stopped.code}`).trim().split("\n")[0]}. Nothing was replaced.`);
1898
+ if (processes === null)
1899
+ return;
1900
+ // The controller drains and exits; every process its record names must be proved gone before anything is replaced.
1901
+ for (let waited = 0;; waited += 250) {
1902
+ const left = processes.filter(one => !processGone(one, probe));
1903
+ if (left.length === 0)
1904
+ return;
1905
+ if (waited >= UP_EXIT_MS)
1906
+ throw Error(`Toolroll's background service is still running (process ${left.map(one => one.pid).join(", ")}) after it was stopped. Nothing was replaced.`);
1907
+ await (seams.sleep ?? sleep)(250);
1908
+ }
1909
+ },
1910
+ restartService: async () => {
1911
+ // The switch rewrote the unit on disk: systemd reads it again before starting it.
1912
+ const reload = await supervise("systemctl", ["--user", "daemon-reload"]);
1913
+ if (reload.code !== 0)
1914
+ throw Error(`systemctl daemon-reload failed: ${(reload.stderr || `exit ${reload.code}`).trim()}`);
1915
+ const started = await supervise("systemctl", ["--user", "start", name]);
1916
+ if (started.code !== 0)
1917
+ throw Error(`systemctl could not start ${LAPTOP_LABEL_LINUX}: ${(started.stderr || `exit ${started.code}`).trim()}`);
1918
+ },
1919
+ healthy: async (j) => {
1920
+ const version = sh(process.execPath, [join(j.to.dist, "bin.js"), "--version"], { timeout: 30_000 });
1921
+ if (version.status !== 0 || version.stdout.trim() !== j.to.version)
1922
+ return false;
1923
+ for (const link of j.switched?.links ?? []) {
1924
+ try {
1925
+ if (realpathSync(link.path) !== realpathSync(join(j.to.dist, "bin.js")))
1926
+ return false;
1927
+ }
1928
+ catch {
1929
+ return false;
1930
+ }
1931
+ }
1932
+ if (!j.switched?.unit?.path)
1933
+ return true;
1934
+ if (!(await active()))
1935
+ return false;
1936
+ const pid = await mainPid();
1937
+ if (pid === null || !sh("/bin/ps", ["-p", String(pid), "-o", "command="]).stdout.includes(j.to.dist))
1938
+ return false;
1939
+ return (seams.consoleProof ?? liveConsoleAt)(j.databaseFile, j.to.version);
1940
+ },
1941
+ };
1942
+ }
1943
+ /** The database's console is proved live (socket and HTTP) and says it is `version`. */
1944
+ async function liveConsoleAt(databaseFile, version) {
1945
+ const { proveLiveConsole } = await import("./console-instance.js");
1946
+ const handle = new (sqlite().DatabaseSync)(databaseFile, { readOnly: true });
1947
+ try {
1948
+ return (await proveLiveConsole(databaseFile, { handle }, version)).ok;
1949
+ }
1950
+ catch {
1951
+ return false;
1952
+ }
1953
+ finally {
1954
+ handle.close();
1955
+ }
1956
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * What the terminal UI (src/tui, bundled into dist/tui.js) reads and asks for, and nothing more. The CLI side
3
+ * (tui-source.ts) implements it with in-process reads of this installation's database and with every write sent
4
+ * through `runOperate` as the local owner (local-owner.ts runAsLocalOwner), so the screen holds no authority of its
5
+ * own: a write the local owner may not make is refused there, and the screen shows the refusal's words.
6
+ *
7
+ * Types only, with no imports: both the CLI build (tsc) and the terminal UI bundle (esbuild) read this file.
8
+ */
9
+ /** One task in the list, as the board says it. */
10
+ export type TuiRow = {
11
+ readonly id: string;
12
+ readonly title: string;
13
+ /** The board's one status word (Needs you, Running, Ready, …). */
14
+ readonly status: string;
15
+ readonly detail: string;
16
+ readonly needsYou: boolean;
17
+ /** The attempt running now, if any: what Stop names. */
18
+ readonly liveRunId: number | null;
19
+ readonly repo: string | null;
20
+ };
21
+ /** A parked question on a task, answered by choosing one of its options. */
22
+ export type TuiDecision = {
23
+ readonly id: number;
24
+ readonly question: string;
25
+ readonly options: readonly {
26
+ readonly id: string;
27
+ readonly label: string;
28
+ }[];
29
+ };
30
+ /** One task in full. */
31
+ export type TuiTask = {
32
+ readonly id: string;
33
+ readonly title: string;
34
+ readonly status: string;
35
+ readonly detail: string;
36
+ /** The plan waiting for approval: its exact digest (what the yes binds) and every line of it. Null: none waits. */
37
+ readonly plan: {
38
+ readonly digest: string;
39
+ readonly lines: readonly string[];
40
+ } | null;
41
+ readonly liveRunId: number | null;
42
+ /** A finished result that can be marked Complete or sent back with a revision. */
43
+ readonly hasResult: boolean;
44
+ readonly decisions: readonly TuiDecision[];
45
+ };
46
+ /** The welcome's steps, as lines (welcome-facts.ts). */
47
+ export type TuiSetup = {
48
+ readonly left: number;
49
+ readonly lines: readonly string[];
50
+ };
51
+ export type TuiSnapshot = {
52
+ readonly rows: readonly TuiRow[];
53
+ /** One line about what waits on the person, or null when nothing does. */
54
+ readonly needsYou: string | null;
55
+ /** The projects tasks can be filed in (the first is the default). */
56
+ readonly projects: readonly string[];
57
+ /** Whether the local owner has set a password (decides where a refusal leads). */
58
+ readonly passwordSet: boolean;
59
+ };
60
+ export type TuiAct = {
61
+ readonly kind: "new";
62
+ readonly text: string;
63
+ readonly repo: string | null;
64
+ } | {
65
+ readonly kind: "approve";
66
+ readonly id: string;
67
+ readonly digest: string;
68
+ } | {
69
+ readonly kind: "stop";
70
+ readonly id: string;
71
+ readonly runId: number;
72
+ } | {
73
+ readonly kind: "revise";
74
+ readonly id: string;
75
+ readonly text: string;
76
+ } | {
77
+ readonly kind: "complete";
78
+ readonly id: string;
79
+ } | {
80
+ readonly kind: "answer";
81
+ readonly decision: number;
82
+ readonly option: string;
83
+ };
84
+ /** A write's answer: its words. `needsPassword`: refused because the local owner may not do it. */
85
+ export type TuiActResult = {
86
+ readonly ok: true;
87
+ readonly message: string;
88
+ readonly id?: string;
89
+ } | {
90
+ readonly ok: false;
91
+ readonly message: string;
92
+ readonly needsPassword: boolean;
93
+ };
94
+ /** The plane, as the screen sees it. */
95
+ export interface TuiSource {
96
+ snapshot(): Promise<TuiSnapshot>;
97
+ task(id: string): Promise<TuiTask | null>;
98
+ /** The newest output lines of the task's live attempt (empty when none runs). */
99
+ live(id: string): readonly string[];
100
+ /** A counter that moves when the plane's work changes (the store's wake sequence). */
101
+ changed(): number;
102
+ act(act: TuiAct): Promise<TuiActResult>;
103
+ /** The welcome's steps, or null once setup is done. */
104
+ setup(): Promise<TuiSetup | null>;
105
+ }
106
+ /** What only the process that opened the screen knows: whether quitting stops its agents, the browser, the ask-once. */
107
+ export interface TuiSession {
108
+ /** Quitting would stop the Toolroll this terminal started (no background service runs it). */
109
+ owned(): boolean;
110
+ /** Open this computer's browser on the console, signed in. Resolves with what to say. */
111
+ openBrowser(): Promise<string>;
112
+ /** Whether to ask, now, "Keep Toolroll running in the background?" (once per installation). */
113
+ shouldAskBackground(): boolean;
114
+ /** Record the answer; yes moves Toolroll to the background service. Resolves with what to say. */
115
+ answerBackground(yes: boolean): Promise<string>;
116
+ }
117
+ /** How the screen ended: leave as it is, stop the agents this terminal started, or keep them running in the background. */
118
+ export type TuiExit = "leave" | "stop" | "keep";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * What the terminal UI (src/tui, bundled into dist/tui.js) reads and asks for, and nothing more. The CLI side
3
+ * (tui-source.ts) implements it with in-process reads of this installation's database and with every write sent
4
+ * through `runOperate` as the local owner (local-owner.ts runAsLocalOwner), so the screen holds no authority of its
5
+ * own: a write the local owner may not make is refused there, and the screen shows the refusal's words.
6
+ *
7
+ * Types only, with no imports: both the CLI build (tsc) and the terminal UI bundle (esbuild) read this file.
8
+ */
9
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { OperateOptions } from "./operate.js";
2
+ import type { ConnectionChecker } from "./provider-connection.js";
3
+ import type { TuiSource } from "./tui-contract.js";
4
+ export type TuiSourceOptions = {
5
+ databaseFile: string;
6
+ clock?: () => Date;
7
+ /** Passed to every runOperate (tests: a fixed clock, seams). */
8
+ operate?: OperateOptions;
9
+ /** The agents' sign-in check for the Setup screen (tests pass their own). */
10
+ check?: ConnectionChecker;
11
+ };
12
+ export declare function createTuiSource(options: TuiSourceOptions): TuiSource & {
13
+ close(): void;
14
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The terminal UI's view of this installation (tui-contract.ts TuiSource), in this process.
3
+ *
4
+ * Reads come straight from the database through a read-only handle (the task list and a task's own envelopes through
5
+ * `runOperate`, live output through peek's tail reader, the store's wake sequence as the change signal). Every write
6
+ * goes through `runOperate` inside runAsLocalOwner: the same commands, rows and refusals as the CLI, bound to local
7
+ * mode when each command opens the database, never to anything the screen says. Every command runs with --json, so
8
+ * nothing ever prompts on the terminal the screen owns.
9
+ */
10
+ import { dirname, join } from "node:path";
11
+ import { assignmentOf } from "./assignment.js";
12
+ import { runAsLocalOwner } from "./local-owner.js";
13
+ import { snapshotLiveRuns } from "./peek-cli.js";
14
+ import { loadProjectRegistry } from "./repos.js";
15
+ import { describeScope } from "./scope.js";
16
+ import { localModeOf, openStoreReadOnly } from "./store.js";
17
+ import { terminalText } from "./terminal-text.js";
18
+ import { welcomeHere, welcomeLines } from "./welcome-facts.js";
19
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
20
+ const words = (value, fallback = "") => typeof value === "string" ? value : fallback;
21
+ /** Text for the screen: whatever wrote it (an agent, a person, a project's name), escapes and controls never reach the
22
+ * terminal (terminal-text.ts). Every displayed string below goes through here; ids and digests are only acted on. */
23
+ const shown = (value, fallback = "") => terminalText(words(value, fallback));
24
+ /** What a refused write says, and whether it is a local-owner refusal (the screen then leads to a password). */
25
+ function refusalOf(envelope) {
26
+ const message = shown(envelope.message, "That was refused.");
27
+ return { ok: false, message, needsPassword: envelope.reason === "refused" || envelope.reason === "machine-proof" || envelope.reason === "person-required" || /password/i.test(message) };
28
+ }
29
+ export function createTuiSource(options) {
30
+ const file = options.databaseFile;
31
+ const clock = options.clock ?? (() => new Date());
32
+ const evidenceRoot = join(dirname(file), "evidence");
33
+ const tails = new Map();
34
+ let reader = null;
35
+ const read = () => {
36
+ reader ??= openStoreReadOnly(file);
37
+ return reader;
38
+ };
39
+ /** One command as the local owner, with --json: its envelope (an unreadable answer reads as a refusal). */
40
+ async function command(argv) {
41
+ const [name = "", ...rest] = argv;
42
+ let out = "";
43
+ const { runOperate } = await import("./operate.js");
44
+ await runAsLocalOwner(() => runOperate(name, [...rest, "--json", "--db", file], text => { out += `${text}\n`; }, options.operate ?? {}));
45
+ try {
46
+ return record(JSON.parse(out.trim()));
47
+ }
48
+ catch {
49
+ const last = out.trim().split("\n").at(-1) ?? "";
50
+ try {
51
+ return record(JSON.parse(last));
52
+ }
53
+ catch {
54
+ return { ok: false, reason: "unreadable", message: "Toolroll's answer couldn't be read." };
55
+ }
56
+ }
57
+ }
58
+ let setupCache = null;
59
+ return {
60
+ async snapshot() {
61
+ const listed = await command(["task", "list"]);
62
+ const tasks = Array.isArray(listed["tasks"]) ? listed["tasks"] : [];
63
+ const rows = tasks.map(one => {
64
+ const row = record(one), status = record(row["status"]);
65
+ const views = Array.isArray(status["views"]) ? status["views"] : [];
66
+ return {
67
+ id: words(row["id"], words(row["rootId"])), title: shown(row["title"]), status: shown(status["label"], words(row["state"])), detail: shown(status["detail"]),
68
+ needsYou: views.includes("needs-you"), liveRunId: typeof row["liveRunId"] === "number" ? row["liveRunId"] : null, repo: typeof row["repo"] === "string" ? row["repo"] : null,
69
+ };
70
+ });
71
+ const waiting = rows.filter(one => one.needsYou);
72
+ const needsYou = waiting.length === 0 ? null : `${waiting.length === 1 ? "1 task needs" : `${waiting.length} tasks need`} you: ${waiting[0].title}`;
73
+ const registry = await loadProjectRegistry(join(dirname(file), "repos.json"));
74
+ const store = read();
75
+ const mode = store === null ? null : localModeOf(store);
76
+ const passwordSet = store !== null && mode !== null && store.accountOf(mode.owner)?.passwordSet === true;
77
+ return { rows, needsYou, projects: "error" in registry ? [] : registry.repos, passwordSet };
78
+ },
79
+ async task(id) {
80
+ const answer = await command(["task", "show", id]);
81
+ if (answer.ok !== true)
82
+ return null;
83
+ const task = record(answer["task"]), work = record(answer["work"]), status = record(work["status"]);
84
+ const store = read();
85
+ const scope = store === null ? null : store.getScope(id);
86
+ const plan = scope === null || scope.approvedDigest === scope.digest ? null : { digest: scope.digest, lines: describeScope(scope).map(line => terminalText(line)) };
87
+ let hasResult = false;
88
+ try {
89
+ hasResult = store !== null && assignmentOf(store, id, clock(), { principal: "operator", repos: null })?.receipt != null;
90
+ }
91
+ catch {
92
+ hasResult = false;
93
+ }
94
+ const decisions = store === null ? [] : store.listDecisions("unanswered").filter(one => one.taskId === id)
95
+ .map(one => ({ id: one.id, question: terminalText(one.question), options: one.options.map(option => ({ id: option.id, label: terminalText(option.label) })) }));
96
+ return {
97
+ id, title: shown(task["title"], id), status: shown(status["label"], words(work["state"])), detail: shown(status["detail"]),
98
+ plan, liveRunId: typeof work["liveRunId"] === "number" ? work["liveRunId"] : null, hasResult, decisions,
99
+ };
100
+ },
101
+ live(id) {
102
+ const store = read();
103
+ if (store === null)
104
+ return [];
105
+ try {
106
+ return snapshotLiveRuns(store, evidenceRoot, clock(), tails).find(one => one.taskId === id)?.lines.map(line => terminalText(line)) ?? [];
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ },
112
+ changed() {
113
+ try {
114
+ return read()?.wakeSeq() ?? 0;
115
+ }
116
+ catch {
117
+ return 0;
118
+ }
119
+ },
120
+ async act(act) {
121
+ let argv;
122
+ let done;
123
+ switch (act.kind) {
124
+ case "new":
125
+ argv = ["task", "add", act.text, ...(act.repo === null ? [] : ["--repo", act.repo])];
126
+ done = "Filed. Its plan will be ready for you to approve.";
127
+ break;
128
+ case "approve":
129
+ argv = ["task", "approve", act.id, "--yes", "--digest", act.digest];
130
+ done = "Approved. An agent starts on it.";
131
+ break;
132
+ case "stop":
133
+ argv = ["task", "stop", act.id, "--run", String(act.runId)];
134
+ done = "Stopping. Its work so far is kept.";
135
+ break;
136
+ case "revise":
137
+ argv = ["task", "revise", act.id, "--feedback", act.text];
138
+ done = "Revision requested.";
139
+ break;
140
+ case "answer":
141
+ argv = ["decide", String(act.decision), "--choose", act.option];
142
+ done = "Answered.";
143
+ break;
144
+ case "complete": {
145
+ const store = read();
146
+ let digest;
147
+ try {
148
+ digest = store === null ? undefined : assignmentOf(store, act.id, clock(), { principal: "operator", repos: null })?.receipt?.digest;
149
+ }
150
+ catch {
151
+ digest = undefined;
152
+ }
153
+ if (digest === undefined)
154
+ return { ok: false, message: "This task has no finished result to mark Complete.", needsPassword: false };
155
+ argv = ["task", "complete", act.id, "--digest", digest];
156
+ done = "Marked Complete.";
157
+ break;
158
+ }
159
+ }
160
+ const answer = await command(argv);
161
+ if (answer.ok !== true)
162
+ return refusalOf(answer);
163
+ const filed = act.kind === "new" ? words(record(answer["task"])["id"], words(answer["id"])) : "";
164
+ return { ok: true, message: done, ...(filed === "" ? {} : { id: filed }) };
165
+ },
166
+ async setup() {
167
+ if (setupCache !== null && Date.now() - setupCache.at < 30_000)
168
+ return setupCache.value;
169
+ const store = read();
170
+ if (store === null)
171
+ return null;
172
+ const here = await welcomeHere(store, file, clock(), options.check === undefined ? {} : { check: options.check });
173
+ const value = here.model.left === 0 ? null : { left: here.model.left, lines: welcomeLines(here.model).map(line => terminalText(line)) };
174
+ setupCache = { at: Date.now(), value };
175
+ return value;
176
+ },
177
+ close() {
178
+ reader?.close();
179
+ reader = null;
180
+ },
181
+ };
182
+ }