toolroll 0.9.63 → 0.9.67

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 (302) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +33 -5
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +117 -2
  16. package/dist/agent-fence.js +439 -12
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +39 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/batch-checks.js +3 -2
  32. package/dist/browser/THIRD_PARTY_NOTICES.txt +94 -0
  33. package/dist/browser/terminal.css +37 -0
  34. package/dist/browser/terminal.js +228 -0
  35. package/dist/browser/workspace.css +2 -2
  36. package/dist/browser/workspace.js +42 -38
  37. package/dist/browser-shell.js +6 -3
  38. package/dist/browser-workspace.d.ts +235 -1
  39. package/dist/browser-workspace.js +60 -0
  40. package/dist/builder.d.ts +10 -1
  41. package/dist/builder.js +87 -24
  42. package/dist/chat-actions.d.ts +13 -1
  43. package/dist/chat-actions.js +197 -15
  44. package/dist/chat-ask.d.ts +4 -0
  45. package/dist/chat-ask.js +14 -0
  46. package/dist/chat-channel.js +20 -0
  47. package/dist/chat-continuity.js +5 -4
  48. package/dist/chat-decide.d.ts +0 -14
  49. package/dist/chat-decide.js +4 -1
  50. package/dist/chat-delivery.d.ts +9 -0
  51. package/dist/chat-delivery.js +45 -6
  52. package/dist/chat-messages.d.ts +8 -0
  53. package/dist/chat-messages.js +36 -4
  54. package/dist/check-levels-ui.js +2 -2
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +19 -0
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +4 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +21 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/console-api.d.ts +13 -4
  71. package/dist/contracts/console-api.js +13 -4
  72. package/dist/contracts/flow-card.d.ts +4 -0
  73. package/dist/contracts/flow-card.js +3 -0
  74. package/dist/contracts/flow.d.ts +46 -0
  75. package/dist/contracts/flow.js +11 -2
  76. package/dist/contracts/lead-tools.d.ts +92 -0
  77. package/dist/contracts/lead-tools.js +13 -0
  78. package/dist/contracts/provider.d.ts +11 -1
  79. package/dist/contracts/provider.js +1 -1
  80. package/dist/contracts/route.d.ts +76 -1
  81. package/dist/contracts/route.js +1 -1
  82. package/dist/contracts/store-json.d.ts +2 -0
  83. package/dist/control-setup.d.ts +1 -1
  84. package/dist/control-ui.js +2 -2
  85. package/dist/converse.d.ts +16 -0
  86. package/dist/converse.js +32 -4
  87. package/dist/demo-chat.js +4 -4
  88. package/dist/demo.js +2 -2
  89. package/dist/desktop-update-gate.js +6 -0
  90. package/dist/desktop-update.d.ts +1 -1
  91. package/dist/discord-chat.js +4 -1
  92. package/dist/doctor.js +1 -1
  93. package/dist/egress-bridge.d.ts +17 -0
  94. package/dist/egress-bridge.js +111 -0
  95. package/dist/egress.d.ts +119 -0
  96. package/dist/egress.js +423 -0
  97. package/dist/exec.d.ts +78 -0
  98. package/dist/exec.js +367 -9
  99. package/dist/export-ui.js +2 -2
  100. package/dist/flow-engine.js +24 -5
  101. package/dist/flow-gallery-ui.js +2 -2
  102. package/dist/flow-gallery.js +27 -8
  103. package/dist/flow-share.js +16 -5
  104. package/dist/flow-triggers.d.ts +77 -8
  105. package/dist/flow-triggers.js +517 -70
  106. package/dist/flows-cli.js +8 -2
  107. package/dist/flows-ui.d.ts +0 -2
  108. package/dist/flows-ui.js +52 -4
  109. package/dist/flows.d.ts +4 -0
  110. package/dist/flows.js +9 -1
  111. package/dist/html.d.ts +9 -0
  112. package/dist/html.js +11 -0
  113. package/dist/invoke.d.ts +35 -0
  114. package/dist/invoke.js +347 -28
  115. package/dist/keys.d.ts +18 -14
  116. package/dist/keys.js +5 -0
  117. package/dist/lead-capabilities.js +3 -2
  118. package/dist/lead-contract.js +2 -0
  119. package/dist/lead-doors.d.ts +2 -0
  120. package/dist/lead-doors.js +7 -0
  121. package/dist/lead-follow.d.ts +2 -0
  122. package/dist/lead-follow.js +58 -2
  123. package/dist/lead-progress.js +1 -0
  124. package/dist/lead-queue-runtime.d.ts +46 -0
  125. package/dist/lead-queue-runtime.js +191 -0
  126. package/dist/lead-queue.d.ts +104 -0
  127. package/dist/lead-queue.js +187 -0
  128. package/dist/lead-sweep.d.ts +67 -0
  129. package/dist/lead-sweep.js +99 -0
  130. package/dist/lead-tools.d.ts +22 -0
  131. package/dist/lead-tools.js +152 -1
  132. package/dist/lead.d.ts +13 -5
  133. package/dist/lead.js +89 -29
  134. package/dist/limits-ui.d.ts +8 -1
  135. package/dist/limits-ui.js +26 -0
  136. package/dist/live-bus.d.ts +8 -0
  137. package/dist/live-bus.js +40 -4
  138. package/dist/live-detail.d.ts +27 -0
  139. package/dist/live-detail.js +57 -0
  140. package/dist/live-notes.d.ts +20 -0
  141. package/dist/live-notes.js +47 -0
  142. package/dist/live-records.d.ts +140 -0
  143. package/dist/live-records.js +935 -0
  144. package/dist/live-terminal.d.ts +269 -0
  145. package/dist/live-terminal.js +686 -0
  146. package/dist/live.d.ts +38 -27
  147. package/dist/live.js +142 -124
  148. package/dist/mcp-oauth.d.ts +6 -3
  149. package/dist/mcp-oauth.js +8 -4
  150. package/dist/memory-pass.js +2 -2
  151. package/dist/mobile-viewport.d.ts +5 -1
  152. package/dist/mobile-viewport.js +18 -2
  153. package/dist/model-catalog.js +4 -0
  154. package/dist/monitoring-ui.js +2 -2
  155. package/dist/needs-you.d.ts +1 -1
  156. package/dist/needs-you.js +11 -4
  157. package/dist/operate.d.ts +4 -0
  158. package/dist/operate.js +335 -19
  159. package/dist/passwordless.d.ts +23 -0
  160. package/dist/passwordless.js +26 -0
  161. package/dist/peek-cli.d.ts +12 -4
  162. package/dist/peek-cli.js +19 -11
  163. package/dist/phase-routing.js +10 -3
  164. package/dist/planner-source.d.ts +3 -0
  165. package/dist/planner-source.js +14 -0
  166. package/dist/planner.d.ts +2 -1
  167. package/dist/planner.js +7 -1
  168. package/dist/policy-ui.js +2 -2
  169. package/dist/policy.d.ts +1 -20
  170. package/dist/policy.js +29 -2
  171. package/dist/project-delete-ui.d.ts +18 -0
  172. package/dist/project-delete-ui.js +60 -9
  173. package/dist/project-delete.js +2 -2
  174. package/dist/project-memory.d.ts +3 -1
  175. package/dist/project-memory.js +57 -25
  176. package/dist/project-tools.d.ts +5 -3
  177. package/dist/project-tools.js +12 -9
  178. package/dist/provider-auth.d.ts +4 -3
  179. package/dist/provider-auth.js +5 -4
  180. package/dist/provider-connection.d.ts +2 -1
  181. package/dist/provider-connection.js +5 -0
  182. package/dist/provider.d.ts +25 -2
  183. package/dist/provider.js +63 -9
  184. package/dist/pull-request-ui.js +2 -2
  185. package/dist/push.js +1 -1
  186. package/dist/quick-start.d.ts +24 -0
  187. package/dist/quick-start.js +127 -0
  188. package/dist/release-adapters.d.ts +6 -0
  189. package/dist/release-adapters.js +70 -3
  190. package/dist/release.d.ts +10 -1
  191. package/dist/release.js +34 -15
  192. package/dist/request-budget-ui.js +2 -2
  193. package/dist/result-follow-ups.js +3 -2
  194. package/dist/result-review.d.ts +1 -1
  195. package/dist/result-review.js +9 -3
  196. package/dist/retention-ui.js +2 -2
  197. package/dist/retention.js +10 -1
  198. package/dist/run-efficiency.d.ts +73 -0
  199. package/dist/run-efficiency.js +228 -0
  200. package/dist/runner.d.ts +5 -1
  201. package/dist/runner.js +22 -1
  202. package/dist/scope.d.ts +183 -5
  203. package/dist/scope.js +256 -31
  204. package/dist/scout-net.d.ts +4 -1
  205. package/dist/scout-net.js +37 -12
  206. package/dist/scout.d.ts +20 -6
  207. package/dist/scout.js +27 -3
  208. package/dist/serve.js +248 -40
  209. package/dist/server/chat.d.ts +1 -1
  210. package/dist/server/chat.js +122 -31
  211. package/dist/server/chrome.d.ts +16 -2
  212. package/dist/server/chrome.js +63 -11
  213. package/dist/server/console.css +107 -42
  214. package/dist/server/flows.js +38 -8
  215. package/dist/server/guards.d.ts +1 -0
  216. package/dist/server/guards.js +23 -9
  217. package/dist/server/http.d.ts +7 -0
  218. package/dist/server/http.js +3 -2
  219. package/dist/server/live.d.ts +34 -1
  220. package/dist/server/live.js +39 -17
  221. package/dist/server/pages-assets.js +5 -0
  222. package/dist/server/pages.js +90 -13
  223. package/dist/server/people-tokens.d.ts +3 -0
  224. package/dist/server/people-tokens.js +55 -17
  225. package/dist/server/remote-hooks.js +12 -6
  226. package/dist/server/remote.d.ts +2 -1
  227. package/dist/server/remote.js +14 -5
  228. package/dist/server/render-chat.d.ts +12 -3
  229. package/dist/server/render-chat.js +47 -16
  230. package/dist/server/render-pages.d.ts +32 -4
  231. package/dist/server/render-pages.js +83 -13
  232. package/dist/server/render-people.d.ts +3 -2
  233. package/dist/server/render-people.js +3 -2
  234. package/dist/server/render-results.d.ts +18 -2
  235. package/dist/server/render-results.js +37 -28
  236. package/dist/server/render-settings.js +6 -5
  237. package/dist/server/render-tasks.d.ts +35 -10
  238. package/dist/server/render-tasks.js +111 -27
  239. package/dist/server/request-context.d.ts +2 -0
  240. package/dist/server/request-context.js +2 -1
  241. package/dist/server/route-table.d.ts +3 -0
  242. package/dist/server/route-table.js +47 -5
  243. package/dist/server/runtime.d.ts +33 -2
  244. package/dist/server/session.d.ts +25 -1
  245. package/dist/server/session.js +18 -4
  246. package/dist/server/settings.js +142 -10
  247. package/dist/server/sweeps.d.ts +4 -0
  248. package/dist/server/sweeps.js +58 -0
  249. package/dist/server/task-views.d.ts +2 -1
  250. package/dist/server/task-views.js +96 -6
  251. package/dist/server/tasks.js +327 -78
  252. package/dist/server/terminals.d.ts +31 -0
  253. package/dist/server/terminals.js +365 -0
  254. package/dist/setup-guide.js +3 -2
  255. package/dist/slack-chat.js +4 -1
  256. package/dist/spend-ui.js +3 -3
  257. package/dist/spend.js +2 -1
  258. package/dist/steer-capabilities.d.ts +32 -0
  259. package/dist/steer-capabilities.js +60 -0
  260. package/dist/steer-controls.d.ts +14 -0
  261. package/dist/steer-controls.js +36 -0
  262. package/dist/steer-status.js +2 -2
  263. package/dist/storage-ui.js +4 -4
  264. package/dist/store-baseline.d.ts +1 -1
  265. package/dist/store-baseline.js +1 -1
  266. package/dist/store.d.ts +440 -15
  267. package/dist/store.js +1072 -52
  268. package/dist/subagent-desk.js +1 -1
  269. package/dist/summary.js +3 -0
  270. package/dist/task-source.d.ts +58 -0
  271. package/dist/task-source.js +93 -0
  272. package/dist/team-leads.js +1 -1
  273. package/dist/teams-chat.js +4 -1
  274. package/dist/teardown-git.d.ts +22 -0
  275. package/dist/teardown-git.js +136 -2
  276. package/dist/telegram-lead.d.ts +3 -0
  277. package/dist/telegram-lead.js +44 -7
  278. package/dist/telegram.d.ts +11 -2
  279. package/dist/telegram.js +51 -17
  280. package/dist/terminal-catalog.d.ts +11 -0
  281. package/dist/terminal-catalog.js +129 -0
  282. package/dist/text-limits.d.ts +2 -0
  283. package/dist/text-limits.js +2 -0
  284. package/dist/tool-broker.d.ts +14 -0
  285. package/dist/tool-broker.js +67 -2
  286. package/dist/toolroll-update-ui.js +2 -2
  287. package/dist/toolroll-update.js +13 -3
  288. package/dist/tools-ui.js +2 -2
  289. package/dist/unfenced-test-support.d.ts +2 -0
  290. package/dist/unfenced-test-support.js +11 -0
  291. package/dist/webhook-limits.d.ts +23 -0
  292. package/dist/webhook-limits.js +30 -0
  293. package/dist/webhook-readers.d.ts +56 -0
  294. package/dist/webhook-readers.js +138 -0
  295. package/dist/webhook-signing.d.ts +27 -0
  296. package/dist/webhook-signing.js +119 -0
  297. package/dist/work-index.d.ts +29 -0
  298. package/dist/work-index.js +16 -2
  299. package/dist/workspace-revision.js +3 -1
  300. package/dist/writes-mode.d.ts +9 -0
  301. package/dist/writes-mode.js +8 -0
  302. package/package.json +15 -2
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Live terminals (docs/plans/live-terminal.md): `claude`, `codex` or a shell running interactively in a real
3
+ * pseudo-terminal, in its own worktree, hosted by the console and streamed to the person's browser.
4
+ *
5
+ * The person drives; nothing here is unattended and nothing here spends on Toolroll's behalf. A session lives
6
+ * while the console runs, attached or not, and ends when its program exits, its owner closes it, or the console
7
+ * stops. Sessions are in memory; the worktree and its branch outlive them.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+ import { createRequire } from "node:module";
11
+ import { execFileSync } from "node:child_process";
12
+ import { existsSync, mkdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
13
+ import { delimiter, isAbsolute, join } from "node:path";
14
+ import { codingEnvironment } from "./coding-provider.js";
15
+ import { teardownGit, refusal } from "./teardown-git.js";
16
+ export const TERMINAL_PROGRAMS = ["claude", "codex", "shell"];
17
+ /** At most this many running sessions per console, and this many kept at all (ended ones count until removed). */
18
+ export const MAX_LIVE_TERMINALS = 8;
19
+ export const MAX_KEPT_TERMINALS = 16;
20
+ /** At most this many pages attached to one session (a desktop, a phone, a spare tab or two). */
21
+ export const MAX_VIEWERS = 4;
22
+ /** After SIGHUP, the terminal's processes have this long before SIGKILL. */
23
+ export const CLOSE_GRACE_MS = 2_000;
24
+ /** Typed input not yet read by the program, per terminal: past this, more is refused until it reads. */
25
+ export const MAX_PENDING_INPUT = 1024 * 1024;
26
+ /** How often a running terminal's processes are noted, so ones that outlive their parent are still known. */
27
+ export const CUSTODY_REFRESH_MS = 5_000;
28
+ export const COLS = { min: 2, max: 500 };
29
+ export const ROWS = { min: 1, max: 200 };
30
+ /** A device path as `ps` prints it: /dev/ttys012 → ttys012, /dev/pts/3 → pts/3. */
31
+ export const ttyName = (path) => typeof path === "string" && /^\/dev\/(ttys\d+|pts\/\d+|tty[a-z]?\d+)$/.test(path) ? path.slice(5) : null;
32
+ /**
33
+ * node-pty's write queue (@lydell/node-pty 1.2.0-beta.15, pinned exactly): typed input waits in its
34
+ * `_writeStream._writeQueue` until the kernel takes it. A shape this doesn't recognise is unmeasurable: Infinity, so
35
+ * input is refused rather than queued without bound (a test pins the shape against the installed package).
36
+ */
37
+ export function pendingInputOf(pty) {
38
+ const queue = pty?._writeStream?._writeQueue;
39
+ if (!Array.isArray(queue))
40
+ return Infinity;
41
+ let bytes = 0;
42
+ for (const task of queue) {
43
+ if (!Buffer.isBuffer(task?.buffer) || typeof task.offset !== "number")
44
+ return Infinity;
45
+ bytes += task.buffer.byteLength - task.offset;
46
+ }
47
+ return bytes;
48
+ }
49
+ /** node-pty, if this install has it (an optional dependency with prebuilt binaries); null when it doesn't load. */
50
+ export function loadPtySpawn() {
51
+ try {
52
+ const pty = createRequire(import.meta.url)("@lydell/node-pty");
53
+ return (file, args, options) => {
54
+ const native = pty.spawn(file, args, { name: "xterm-256color", cwd: options.cwd, env: options.env, cols: options.cols, rows: options.rows });
55
+ return {
56
+ get pid() { return native.pid; },
57
+ tty: ttyName(native.ptsName),
58
+ pendingInput: () => pendingInputOf(native),
59
+ onData: listener => native.onData(listener),
60
+ onExit: listener => native.onExit(listener),
61
+ write: data => native.write(data),
62
+ resize: (cols, rows) => native.resize(cols, rows),
63
+ kill: signal => native.kill(signal),
64
+ };
65
+ };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /** How many lines above the screen a terminal keeps for a page that attaches later. */
72
+ export const SCREEN_SCROLLBACK_LINES = 5000;
73
+ const require = createRequire(import.meta.url);
74
+ /**
75
+ * The terminal as a page would see it now: every byte the program writes is parsed by a headless xterm here, so a page
76
+ * that attaches later gets the screen, its scrollback and its modes (the alternate screen of a full-screen program, the
77
+ * cursor, colours, bracketed paste, mouse reporting) — never a replay of raw output cut at an arbitrary byte, which
78
+ * could split an escape sequence or redraw a full-screen program garbled.
79
+ */
80
+ export class ScreenMirror {
81
+ lines;
82
+ terminal;
83
+ serializer;
84
+ constructor(cols, rows, lines = SCREEN_SCROLLBACK_LINES) {
85
+ this.lines = lines;
86
+ const { Terminal } = require("@xterm/headless");
87
+ const { SerializeAddon } = require("@xterm/addon-serialize");
88
+ this.terminal = new Terminal({ cols, rows, scrollback: lines, allowProposedApi: true });
89
+ this.serializer = new SerializeAddon();
90
+ this.terminal.loadAddon(this.serializer);
91
+ }
92
+ write(data) { this.terminal.write(data); }
93
+ resize(cols, rows) { try {
94
+ this.terminal.resize(cols, rows);
95
+ }
96
+ catch { /* sizes are clamped before this */ } }
97
+ /** The screen as of this call (after everything written before it), handed to `done` once parsed. */
98
+ snapshot(done) {
99
+ this.terminal.write("", () => { let screen = ""; try {
100
+ screen = this.serializer.serialize({ scrollback: this.lines });
101
+ }
102
+ catch {
103
+ screen = "";
104
+ } done(screen); });
105
+ }
106
+ dispose() { try {
107
+ this.terminal.dispose();
108
+ }
109
+ catch { /* already */ } }
110
+ }
111
+ const NO_LEDGER = { reserve() { }, running() { }, release() { }, forget() { } };
112
+ export class TerminalError extends Error {
113
+ constructor(message) { super(message); this.name = "TerminalError"; }
114
+ }
115
+ const clamp = (value, range) => Math.min(range.max, Math.max(range.min, Math.trunc(value)));
116
+ /** The executable on PATH, or null. */
117
+ export function onPath(name, env = process.env) {
118
+ if (isAbsolute(name))
119
+ return existsSync(name) ? name : null;
120
+ const extensions = process.platform === "win32" ? (env["PATHEXT"] ?? ".EXE;.CMD;.BAT").split(";") : [""];
121
+ for (const dir of (env["PATH"] ?? "").split(delimiter)) {
122
+ if (dir === "")
123
+ continue;
124
+ for (const extension of extensions) {
125
+ const path = join(dir, name + extension);
126
+ try {
127
+ if (statSync(path).isFile())
128
+ return path;
129
+ }
130
+ catch { /* not here */ }
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ function defaultProgram(program, env) {
136
+ if (program === "shell") {
137
+ if (process.platform === "win32")
138
+ return { file: onPath("pwsh", env) ?? onPath("powershell", env) ?? "cmd.exe", args: [] };
139
+ const shell = env["SHELL"];
140
+ const file = shell !== undefined && isAbsolute(shell) && existsSync(shell) ? shell : onPath("bash", env) ?? "/bin/sh";
141
+ return { file, args: ["-l"] };
142
+ }
143
+ const file = onPath(program, env);
144
+ return file === null ? null : { file, args: [] };
145
+ }
146
+ export class TerminalHost {
147
+ options;
148
+ sessions = new Map();
149
+ starting = 0;
150
+ stopped = false;
151
+ clock;
152
+ env;
153
+ ledger;
154
+ processes;
155
+ custodyTimer = null;
156
+ constructor(options) {
157
+ this.options = options;
158
+ this.clock = options.clock ?? (() => new Date());
159
+ this.env = options.env ?? codingEnvironment;
160
+ this.ledger = options.ledger ?? NO_LEDGER;
161
+ this.processes = options.processes ?? readProcessTable;
162
+ }
163
+ summary(session) {
164
+ const { id, owner, generation, repo, program, branch, worktree, startedAt, endedAt, exit, endedBy, cleanup } = session;
165
+ return { id, owner, generation, repo, program, branch, worktree, startedAt, endedAt, exit, endedBy, cleanup };
166
+ }
167
+ /** This person's sessions, newest first. */
168
+ list(owner) {
169
+ return [...this.sessions.values()].filter(one => one.owner === owner).map(one => this.summary(one)).reverse();
170
+ }
171
+ get(id) {
172
+ const session = this.sessions.get(id);
173
+ return session === undefined ? null : this.summary(session);
174
+ }
175
+ running() {
176
+ return [...this.sessions.values()].filter(one => one.endedAt === null).length;
177
+ }
178
+ /**
179
+ * Make the worktree and start the program in it. `admit` is asked again right before the program starts (after the
180
+ * slow git work): a person whose access or project went in between starts nothing.
181
+ */
182
+ async start({ name: owner, generation }, repoPath, program, size = { cols: 100, rows: 30 }, admit = () => null) {
183
+ if (this.stopped)
184
+ throw new TerminalError("The console is stopping.");
185
+ if (!TERMINAL_PROGRAMS.includes(program))
186
+ throw new TerminalError("Choose Claude, Codex or a shell.");
187
+ if (this.running() + this.starting >= (this.options.maxSessions ?? MAX_LIVE_TERMINALS))
188
+ throw new TerminalError(`At most ${this.options.maxSessions ?? MAX_LIVE_TERMINALS} terminals can run at once. Close one first.`);
189
+ if (this.sessions.size + this.starting >= MAX_KEPT_TERMINALS)
190
+ throw new TerminalError(`${MAX_KEPT_TERMINALS} terminals are kept already. Remove an ended one first.`);
191
+ const env = { ...this.env(), TERM: "xterm-256color", COLORTERM: "truecolor" };
192
+ const command = this.options.programs?.[program] ?? defaultProgram(program, env);
193
+ if (command === null)
194
+ throw new TerminalError(`${program === "claude" ? "Claude Code" : "Codex"} isn't installed on this computer (\`${program}\` is not on PATH).`);
195
+ const id = randomBytes(16).toString("hex");
196
+ // The slot is held, here and for the updater, before any slow work.
197
+ this.starting += 1;
198
+ let reserved = false;
199
+ try {
200
+ try {
201
+ this.ledger.reserve(id);
202
+ reserved = true;
203
+ }
204
+ catch (error) {
205
+ throw error instanceof TerminalError ? error : new TerminalError(error instanceof Error ? error.message : "No terminal can start right now.");
206
+ }
207
+ let repo;
208
+ try {
209
+ repo = realpathSync(repoPath);
210
+ }
211
+ catch {
212
+ throw new TerminalError("That project folder is gone.");
213
+ }
214
+ const hardened = this.options.git ?? teardownGit();
215
+ const git = async (args) => {
216
+ const result = await hardened(repo, args);
217
+ if (result.code !== 0)
218
+ throw new TerminalError(refusal(result) !== null ? `Toolroll won't run git in this project: ${refusal(result)}.` : "Git couldn't make a worktree for this terminal. Check the project and try again.");
219
+ return result.stdout.trim();
220
+ };
221
+ if (realpathSync(await git(["rev-parse", "--show-toplevel"])) !== repo)
222
+ throw new TerminalError("Choose the root of a Git project.");
223
+ const base = await git(["rev-parse", "--verify", "HEAD^{commit}"]);
224
+ const branch = `toolroll/live-${id.slice(0, 12)}`;
225
+ const worktree = join(this.options.worktreeRoot, id);
226
+ mkdirSync(this.options.worktreeRoot, { recursive: true, mode: 0o700 });
227
+ await git(["worktree", "add", "-b", branch, "--", worktree, base]);
228
+ const cols = clamp(size.cols, COLS), rows = clamp(size.rows, ROWS);
229
+ let pty;
230
+ try {
231
+ if (this.stopped)
232
+ throw new TerminalError("The console is stopping.");
233
+ const refused = admit();
234
+ if (refused !== null)
235
+ throw new TerminalError(refused);
236
+ pty = this.options.spawn(command.file, command.args, { cwd: worktree, env, cols, rows });
237
+ }
238
+ catch (error) {
239
+ // Nothing ran in it: take the worktree and branch back.
240
+ await git(["worktree", "remove", "--force", "--", worktree]).catch(() => { });
241
+ await git(["branch", "-D", "--", branch]).catch(() => { });
242
+ throw error instanceof TerminalError ? error : new TerminalError(`The program couldn't start: ${error instanceof Error ? error.message : String(error)}`);
243
+ }
244
+ let resolveExited;
245
+ const session = { id, owner, generation, repo, program, branch, worktree, startedAt: this.clock().toISOString(), endedAt: null, exit: null, endedBy: null, cleanup: null,
246
+ pty, leader: pty.pid, tty: pty.tty, known: new Map(), exitedAt: null, worktreeReal: realpathSync(worktree), startedMs: Date.now(), residentsOnly: false, residentsSeen: new Map(), cols, rows, screen: new ScreenMirror(cols, rows, this.options.scrollbackLines), viewers: new Set(), closing: null,
247
+ exited: new Promise(resolve => { resolveExited = resolve; }) };
248
+ this.sessions.set(id, session);
249
+ this.ledger.running(id);
250
+ reserved = false;
251
+ this.noteCustody();
252
+ pty.onData(data => {
253
+ this.noteSoon();
254
+ const chunk = Buffer.from(data, "utf8");
255
+ session.screen.write(data);
256
+ for (const viewer of session.viewers) {
257
+ try {
258
+ viewer.output(chunk);
259
+ }
260
+ catch { /* the viewer's own close handles it */ }
261
+ }
262
+ });
263
+ pty.onExit(({ exitCode, signal }) => {
264
+ session.pty = null;
265
+ session.exitedAt = Date.now();
266
+ session.endedAt = this.clock().toISOString();
267
+ session.exit = { code: exitCode ?? null, signal: signal || null };
268
+ for (const viewer of session.viewers) {
269
+ try {
270
+ viewer.control({ t: "exit", code: session.exit.code, signal: session.exit.signal });
271
+ }
272
+ catch { /* gone */ }
273
+ }
274
+ this.options.record?.("exit", this.summary(session));
275
+ resolveExited();
276
+ // A program that ended on its own takes its jobs with it, as closing a terminal window does.
277
+ if (session.closing === null)
278
+ session.closing = this.sweep(session);
279
+ });
280
+ this.options.record?.("start", this.summary(session));
281
+ return this.summary(session);
282
+ }
283
+ finally {
284
+ this.starting -= 1;
285
+ if (reserved)
286
+ this.ledger.release(id, null);
287
+ }
288
+ }
289
+ /**
290
+ * Attach a page: it gets the screen as it is now (ScreenMirror), then `hello`, then live output — what the program
291
+ * writes meanwhile is held and sent after the screen, in order. Returns its detach.
292
+ */
293
+ attach(id, viewer) {
294
+ const session = this.sessions.get(id);
295
+ if (session === undefined)
296
+ throw new TerminalError("No such terminal.");
297
+ if (session.viewers.size >= MAX_VIEWERS)
298
+ throw new TerminalError(`This terminal is open in ${MAX_VIEWERS} places already. Close one of them.`);
299
+ const held = [];
300
+ let live = false;
301
+ const gate = {
302
+ output: data => { if (live)
303
+ viewer.output(data);
304
+ else
305
+ held.push({ output: data }); },
306
+ control: message => { if (live)
307
+ viewer.control(message);
308
+ else
309
+ held.push({ control: message }); },
310
+ };
311
+ session.viewers.add(gate);
312
+ session.screen.snapshot(screen => {
313
+ if (!session.viewers.has(gate))
314
+ return;
315
+ try {
316
+ if (screen !== "")
317
+ viewer.output(Buffer.from(screen, "utf8"));
318
+ viewer.control({ t: "hello", cols: session.cols, rows: session.rows, program: session.program, ended: session.endedAt !== null });
319
+ if (session.exit !== null && !held.some(one => "control" in one && one.control.t === "exit"))
320
+ viewer.control({ t: "exit", code: session.exit.code, signal: session.exit.signal });
321
+ for (const one of held) {
322
+ if ("output" in one)
323
+ viewer.output(one.output);
324
+ else
325
+ viewer.control(one.control);
326
+ }
327
+ }
328
+ catch { /* the viewer's own close handles it */ }
329
+ held.length = 0;
330
+ live = true;
331
+ });
332
+ return () => { session.viewers.delete(gate); };
333
+ }
334
+ /** How many pages are attached now. */
335
+ viewerCount(id) { return this.sessions.get(id)?.viewers.size ?? 0; }
336
+ /** Type into the program. False (nothing queued) while it has MAX_PENDING_INPUT unread, or can't be measured. */
337
+ input(id, data) {
338
+ const pty = this.sessions.get(id)?.pty;
339
+ if (pty == null)
340
+ return true;
341
+ if (pty.pendingInput() + Buffer.byteLength(data) > MAX_PENDING_INPUT)
342
+ return false;
343
+ pty.write(data);
344
+ this.noteSoon();
345
+ return true;
346
+ }
347
+ resize(id, cols, rows) {
348
+ const session = this.sessions.get(id);
349
+ if (session?.pty == null)
350
+ return;
351
+ session.cols = clamp(cols, COLS);
352
+ session.rows = clamp(rows, ROWS);
353
+ try {
354
+ session.pty.resize(session.cols, session.rows);
355
+ }
356
+ catch { /* it exited between */ }
357
+ session.screen.resize(session.cols, session.rows);
358
+ }
359
+ noteTimer = null;
360
+ /** Activity (output, typing) usually means a command started: note processes shortly after, at most every 250 ms. */
361
+ noteSoon() {
362
+ if (this.noteTimer !== null)
363
+ return;
364
+ this.noteTimer = setTimeout(() => { this.noteTimer = null; this.noteCustody(); }, 250);
365
+ this.noteTimer.unref?.();
366
+ }
367
+ /** Note every running terminal's processes now, and keep doing so while any runs. */
368
+ noteCustody() {
369
+ const live = [...this.sessions.values()].filter(one => one.endedAt === null);
370
+ if (live.length > 0) {
371
+ const table = this.processes();
372
+ if (table !== null)
373
+ for (const session of live)
374
+ for (const [pid, started] of membersOf(table, session))
375
+ session.known.set(pid, started);
376
+ }
377
+ if (live.length > 0 && this.custodyTimer === null) {
378
+ this.custodyTimer = setInterval(() => this.noteCustody(), CUSTODY_REFRESH_MS);
379
+ this.custodyTimer.unref?.();
380
+ }
381
+ else if (live.length === 0 && this.custodyTimer !== null) {
382
+ clearInterval(this.custodyTimer);
383
+ this.custodyTimer = null;
384
+ }
385
+ }
386
+ /**
387
+ * End the program and everything that is its own: SIGHUP to every member (an interactive shell gives each job its own
388
+ * process group, so the leader's group is not enough), then SIGKILL, re-reading the process table each time so a
389
+ * child started during the close is caught too. Membership is the leader's descendants, every process with this
390
+ * terminal as its controlling terminal (started before the program ended), and every one noted while it ran, by pid
391
+ * and start time. A process that detached itself from the terminal AND its parents before it was noted is not seen —
392
+ * the same as any terminal. Resolves when the sweep is done; never waits without bound.
393
+ */
394
+ close(id, by = "owner") {
395
+ const session = this.sessions.get(id);
396
+ if (session === undefined)
397
+ return Promise.resolve();
398
+ if (session.closing !== null)
399
+ return session.closing;
400
+ session.endedBy = by;
401
+ this.options.record?.("close", this.summary(session));
402
+ session.closing = this.sweep(session);
403
+ return session.closing;
404
+ }
405
+ async sweep(session) {
406
+ const grace = this.options.closeGraceMs ?? CLOSE_GRACE_MS;
407
+ const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
408
+ const leaderGone = () => session.pty === null;
409
+ let readable = true;
410
+ const members = () => {
411
+ const table = this.processes();
412
+ if (table === null) {
413
+ readable = false;
414
+ return new Map();
415
+ }
416
+ const found = membersOf(table, session);
417
+ for (const [pid, started] of found)
418
+ session.known.set(pid, started);
419
+ return found;
420
+ };
421
+ const signal = (found, name) => {
422
+ if (!leaderGone()) {
423
+ try {
424
+ process.kill(-session.leader, name);
425
+ }
426
+ catch { /* gone */ }
427
+ try {
428
+ session.pty?.kill(name);
429
+ }
430
+ catch { /* gone */ }
431
+ }
432
+ for (const pid of found.keys()) {
433
+ try {
434
+ process.kill(pid, name);
435
+ }
436
+ catch { /* gone */ }
437
+ }
438
+ };
439
+ let cleanup = "uncertain";
440
+ try {
441
+ if (process.platform === "win32") {
442
+ try {
443
+ session.pty?.kill();
444
+ }
445
+ catch { /* gone */ }
446
+ await Promise.race([session.exited, pause(grace)]);
447
+ cleanup = leaderGone() ? "clean" : "uncertain";
448
+ return;
449
+ }
450
+ signal(members(), "SIGHUP");
451
+ for (const deadline = Date.now() + grace; Date.now() < deadline;) {
452
+ await pause(100);
453
+ if (leaderGone() && members().size === 0 && readable) {
454
+ cleanup = "clean";
455
+ return;
456
+ }
457
+ }
458
+ for (let round = 0; round < 20; round += 1) {
459
+ const found = members();
460
+ if (!readable)
461
+ return;
462
+ if (found.size === 0 && leaderGone()) {
463
+ cleanup = "clean";
464
+ return;
465
+ }
466
+ signal(found, "SIGKILL");
467
+ await pause(100);
468
+ }
469
+ }
470
+ finally {
471
+ // The leader's exit is waited for, but never without bound.
472
+ await Promise.race([session.exited, pause(1_000)]);
473
+ // Proven members gone is not enough to free the worktree: nothing of this user may still be working in it.
474
+ if (cleanup === "clean") {
475
+ const table = this.processes();
476
+ const residents = table === null ? null : (this.options.residents ?? worktreeResidents)(table, session.worktreeReal, session.startedMs);
477
+ if (residents === null || residents.size > 0) {
478
+ cleanup = "uncertain";
479
+ session.residentsOnly = residents !== null;
480
+ // Who was there, by pid and start time: removal waits for each to exit, not merely to leave the folder.
481
+ for (const pid of residents ?? []) {
482
+ const row = table.get(pid);
483
+ if (row !== undefined)
484
+ session.residentsSeen.set(pid, row.started);
485
+ }
486
+ }
487
+ }
488
+ session.cleanup = readable && leaderGone() ? cleanup : "uncertain";
489
+ if (session.endedAt === null)
490
+ session.endedAt = this.clock().toISOString();
491
+ this.ledger.release(session.id, session.cleanup);
492
+ this.noteCustody();
493
+ }
494
+ }
495
+ /** Drop an ended session and its worktree (the branch stays: the work is there). Only after a clean sweep. */
496
+ async remove(id) {
497
+ const session = this.sessions.get(id);
498
+ if (session === undefined)
499
+ return;
500
+ if (session.pty !== null)
501
+ throw new TerminalError("Close the terminal before removing it.");
502
+ if (session.closing !== null)
503
+ await session.closing;
504
+ const busy = new TerminalError("Something may still be running in this terminal's worktree, so the worktree stays. Stop it (or leave the folder), then remove the worktree with `git worktree remove`.");
505
+ // Uncertain only because someone else was working in the worktree: it may go once they have left (checked below).
506
+ if (session.cleanup !== "clean" && !session.residentsOnly)
507
+ throw busy;
508
+ // Checked again now: a shell or server may have moved into the worktree since the sweep.
509
+ const table = this.processes();
510
+ const residents = table === null ? null : (this.options.residents ?? worktreeResidents)(table, session.worktreeReal, session.startedMs);
511
+ if (residents === null || residents.size > 0)
512
+ throw busy;
513
+ // Each process seen working in it when the sweep ended must have exited (the same pid with the same start time is
514
+ // the same process, wherever it now works).
515
+ if ([...session.residentsSeen].some(([pid, started]) => table.get(pid)?.started === started))
516
+ throw busy;
517
+ this.sessions.delete(id);
518
+ this.ledger.forget(id);
519
+ for (const viewer of session.viewers) {
520
+ try {
521
+ viewer.control({ t: "exit", code: session.exit?.code ?? null, signal: session.exit?.signal ?? null });
522
+ }
523
+ catch { /* gone */ }
524
+ }
525
+ session.viewers.clear();
526
+ session.screen.dispose();
527
+ const result = await (this.options.git ?? teardownGit())(session.repo, ["worktree", "remove", "--force", "--", session.worktree]);
528
+ this.options.record?.("remove", this.summary(session));
529
+ if (result.code !== 0 && existsSync(session.worktree)) {
530
+ const why = refusal(result);
531
+ throw new TerminalError(`The terminal is gone, but its worktree stays${why === null ? "" : ` (${why})`}. Remove it with \`git worktree remove\`.`);
532
+ }
533
+ }
534
+ /** Whether new sessions may start (false once the console is stopping). */
535
+ get admitting() { return !this.stopped; }
536
+ /**
537
+ * The console is stopping: admit nothing more, end every program, and wait (each sweep is bounded) for them.
538
+ * `clean` is false when any terminal's processes couldn't be proved gone.
539
+ */
540
+ async stop() {
541
+ this.stopped = true;
542
+ await Promise.all([...this.sessions.keys()].map(id => this.close(id, "console")));
543
+ if (this.custodyTimer !== null) {
544
+ clearInterval(this.custodyTimer);
545
+ this.custodyTimer = null;
546
+ }
547
+ if (this.noteTimer !== null) {
548
+ clearTimeout(this.noteTimer);
549
+ this.noteTimer = null;
550
+ }
551
+ return { clean: [...this.sessions.values()].every(one => one.cleanup === "clean") };
552
+ }
553
+ }
554
+ /** Every process from `ps` (macOS and Linux take these options); null when it can't be read, never an empty guess. */
555
+ export function readProcessTable() {
556
+ let text;
557
+ try {
558
+ text = execFileSync("ps", ["-A", "-o", "pid=,ppid=,uid=,tty=,lstart="], { encoding: "utf8", timeout: 2_000, maxBuffer: 8 * 1024 * 1024 });
559
+ }
560
+ catch {
561
+ return null;
562
+ }
563
+ const table = new Map();
564
+ for (const line of text.split("\n")) {
565
+ if (line.trim() === "")
566
+ continue;
567
+ // uid -2 is macOS's "nobody".
568
+ const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
569
+ if (match === null)
570
+ return null;
571
+ table.set(Number(match[1]), { ppid: Number(match[2]), uid: Number(match[3]), tty: match[4], started: match[5] });
572
+ }
573
+ return table.size === 0 ? null : table;
574
+ }
575
+ /**
576
+ * A terminal's processes in this table, by pid and start time: what was noted while it ran (still the same process),
577
+ * the leader, everything whose controlling terminal is its terminal and that started before it ended (a later process
578
+ * on a reused device is someone else's), and every descendant of those. Never this console itself.
579
+ */
580
+ export function membersOf(table, session) {
581
+ const found = new Map();
582
+ const startedMs = (started) => Date.parse(started);
583
+ for (const [pid, row] of table) {
584
+ if (pid === process.pid || pid <= 1)
585
+ continue;
586
+ const noted = session.known.get(pid);
587
+ const ownTty = session.tty !== null && row.tty === session.tty && (session.exitedAt === null || startedMs(row.started) <= session.exitedAt + 1_000);
588
+ // The leader by pid only while it runs: after it exits its pid may be someone else's (it is noted by start time).
589
+ if (noted === row.started || (pid === session.leader && session.exitedAt === null) || ownTty)
590
+ found.set(pid, row.started);
591
+ }
592
+ for (let grew = true; grew;) {
593
+ grew = false;
594
+ for (const [pid, row] of table)
595
+ if (!found.has(pid) && pid !== process.pid && found.has(row.ppid)) {
596
+ found.set(pid, row.started);
597
+ grew = true;
598
+ }
599
+ }
600
+ return found;
601
+ }
602
+ /**
603
+ * Each listed process's working directory (Linux: /proc; macOS: lsof), and which ones couldn't be read (a setgid
604
+ * helper such as ssh-agent is unreadable even to its own user). Null when the lookup itself failed: a timeout, a signal
605
+ * or partial output is never a smaller answer. A process gone in between is simply absent.
606
+ */
607
+ export function cwdsOf(pids) {
608
+ const cwds = new Map(), unreadable = [];
609
+ if (pids.length === 0)
610
+ return { cwds, unreadable };
611
+ if (process.platform === "linux") {
612
+ for (const pid of pids) {
613
+ try {
614
+ cwds.set(pid, readlinkSync(`/proc/${pid}/cwd`));
615
+ }
616
+ catch (error) {
617
+ const code = error.code;
618
+ if (code === "EACCES" || code === "EPERM")
619
+ unreadable.push(pid);
620
+ else if (code !== "ENOENT")
621
+ return null;
622
+ }
623
+ }
624
+ return { cwds, unreadable };
625
+ }
626
+ let text;
627
+ try {
628
+ text = execFileSync("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8", timeout: 10_000, maxBuffer: 16 * 1024 * 1024 });
629
+ }
630
+ catch (error) {
631
+ // lsof exits 1 (and nothing else) when some listed process is gone; a timeout or a signal is unreadable.
632
+ const failure = error;
633
+ if (failure.status !== 1 || failure.signal != null || failure.code === "ETIMEDOUT" || typeof failure.stdout !== "string")
634
+ return null;
635
+ text = failure.stdout;
636
+ }
637
+ let pid = null;
638
+ for (const line of text.split("\n")) {
639
+ if (line.startsWith("p"))
640
+ pid = Number(line.slice(1));
641
+ else if (line.startsWith("n") && pid !== null)
642
+ cwds.set(pid, line.slice(1));
643
+ }
644
+ return { cwds, unreadable };
645
+ }
646
+ /**
647
+ * This user's processes working inside a terminal's worktree, whoever started them. They are never signalled (one may
648
+ * be the person's own shell or a server they started from elsewhere); while any remains, the terminal's cleanup is
649
+ * uncertain and its worktree stays. Null when that can't be known: the lookup failed, or a process whose working
650
+ * directory can't be read started after the terminal did (one from before — a login's ssh-agent — can't be its own).
651
+ */
652
+ export function worktreeResidents(table, worktreeReal, terminalStartedMs = 0) {
653
+ const uid = process.getuid?.() ?? -1;
654
+ const candidates = [...table].filter(([pid, row]) => pid !== process.pid && row.uid === uid).map(([pid]) => pid);
655
+ const read = cwdsOf(candidates);
656
+ if (read === null)
657
+ return null;
658
+ if (read.unreadable.some(pid => { const started = table.get(pid)?.started; return started === undefined || !(Date.parse(started) < terminalStartedMs - 1_000); }))
659
+ return null;
660
+ return new Set([...read.cwds].filter(([, cwd]) => cwd === worktreeReal || cwd.startsWith(`${worktreeReal}/`)).map(([pid]) => pid));
661
+ }
662
+ export const MAX_FRAME_BYTES = 64 * 1024;
663
+ export function parseTerminalFrame(text) {
664
+ if (Buffer.byteLength(text) > MAX_FRAME_BYTES)
665
+ return null;
666
+ let value;
667
+ try {
668
+ value = JSON.parse(text);
669
+ }
670
+ catch {
671
+ return null;
672
+ }
673
+ if (value === null || typeof value !== "object" || Array.isArray(value))
674
+ return null;
675
+ const frame = value;
676
+ const keys = Object.keys(frame).sort().join(",");
677
+ if (frame["t"] === "in" && keys === "d,t" && typeof frame["d"] === "string")
678
+ return { t: "in", d: frame["d"] };
679
+ if (frame["t"] === "resize" && keys === "cols,rows,t") {
680
+ const cols = frame["cols"], rows = frame["rows"];
681
+ if (Number.isSafeInteger(cols) && Number.isSafeInteger(rows) && cols >= COLS.min && cols <= COLS.max && rows >= ROWS.min && rows <= ROWS.max) {
682
+ return { t: "resize", cols: cols, rows: rows };
683
+ }
684
+ }
685
+ return null;
686
+ }