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