@ccmsg/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import type { AgentInfo, Sid, Timestamp } from "@ccmsg/protocol";
|
|
2
|
+
import { OpError } from "../dispatch/index.ts";
|
|
3
|
+
import type { TerminalReader } from "./terminals.ts";
|
|
4
|
+
|
|
5
|
+
/** How long a child this instance runs to observe a process may take. A wedged
|
|
6
|
+
* reader must not hold a request open; the answer it would have given is worth
|
|
7
|
+
* less than the connection it would hold. */
|
|
8
|
+
const CHILD_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
/** The two-shot termination of a session, in the intervals it was measured at.
|
|
11
|
+
*
|
|
12
|
+
* The harness's first SIGTERM only arms its quit confirmation, so a second is
|
|
13
|
+
* sent to a process that is still there a second later. Nothing escalates to
|
|
14
|
+
* SIGKILL on its own: an unconditional kill forfeits the session's chance to
|
|
15
|
+
* flush its transcript, which the contract says a caller asks for after
|
|
16
|
+
* watching a graceful attempt go unconfirmed. */
|
|
17
|
+
export const SECOND_SIGNAL_AFTER_MS = 1_000;
|
|
18
|
+
export const GRACE_MS = 3_000;
|
|
19
|
+
/** How often the process is asked whether it is still there.
|
|
20
|
+
*
|
|
21
|
+
* The confirmation is not observable any other way: a process's own exit is
|
|
22
|
+
* reported to its parent, and this instance is not one. Polling `kill(pid, 0)`
|
|
23
|
+
* is the route the platform leaves, and the interval bounds how long a
|
|
24
|
+
* finished kill reads as unconfirmed. */
|
|
25
|
+
export const LIVENESS_POLL_MS = 200;
|
|
26
|
+
|
|
27
|
+
/** How far apart the row's `startedAt` and the process's own start time may be
|
|
28
|
+
* and still be one process, in each direction.
|
|
29
|
+
*
|
|
30
|
+
* The two are different events: the process starts, and the harness writes the
|
|
31
|
+
* row once it has come up. So the process is the earlier of the two by however
|
|
32
|
+
* long that took, which a session that waits on a prompt before writing its
|
|
33
|
+
* row can stretch to a minute; and it is later than the row only by
|
|
34
|
+
* measurement error, which is `ps` stating how long the process has been
|
|
35
|
+
* running truncated to the second.
|
|
36
|
+
*
|
|
37
|
+
* A recycled pid is nowhere near either bound: its process began after the
|
|
38
|
+
* row's process exited, which is the whole life of a session later. */
|
|
39
|
+
export const STARTED_BEFORE_TOLERANCE_MS = 120_000;
|
|
40
|
+
export const STARTED_AFTER_TOLERANCE_MS = 5_000;
|
|
41
|
+
|
|
42
|
+
/** What acting on a session's process needs from the world around it.
|
|
43
|
+
*
|
|
44
|
+
* Every effect is injectable because the alternative is a test that signals
|
|
45
|
+
* real processes: the rows come from the harness's own directory, the signal
|
|
46
|
+
* and the liveness probe are the platform's, and the two readers are children.
|
|
47
|
+
*/
|
|
48
|
+
export interface ProcessDeps {
|
|
49
|
+
/** The harness's sessions, read now rather than from a watch's cache. Only
|
|
50
|
+
* this instance's config home is ever read (M6). */
|
|
51
|
+
readonly rows: () => ReadonlyMap<Sid, AgentInfo>;
|
|
52
|
+
/** What the process is running, as `ps` states argv. */
|
|
53
|
+
readonly command: (pid: number) => Promise<string>;
|
|
54
|
+
/** The process's own environment, as the platform exposes it. */
|
|
55
|
+
readonly environment: (pid: number) => Promise<string>;
|
|
56
|
+
/** When the process was started, as the platform states it. Undefined when
|
|
57
|
+
* the platform stated something this instance could not read as an instant. */
|
|
58
|
+
readonly started: (pid: number) => Promise<Timestamp | undefined>;
|
|
59
|
+
readonly signal: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
|
|
60
|
+
readonly alive: (pid: number) => boolean;
|
|
61
|
+
readonly sleep: (ms: number) => Promise<void>;
|
|
62
|
+
readonly platform: () => NodeJS.Platform;
|
|
63
|
+
/** What a rename types with, when this instance has one configured. */
|
|
64
|
+
readonly type?: (terminal: Terminal, keys: readonly string[]) => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The terminal a session runs in, as its own process names it. */
|
|
68
|
+
export interface Terminal {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
/** Absent means the process set none, which the multiplexer reads as its own
|
|
71
|
+
* default — not this instance's namespace, which can differ. */
|
|
72
|
+
readonly namespace?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The environment variables a session's terminal is named by. */
|
|
76
|
+
const TERMINAL_ID = "HYOUI_SESSION_ID";
|
|
77
|
+
const TERMINAL_NAMESPACE = "HYOUI_NAMESPACE";
|
|
78
|
+
|
|
79
|
+
/** The terminal an environment names, or nothing when it names none. */
|
|
80
|
+
export function terminalOf(env: Record<string, string>): Terminal | undefined {
|
|
81
|
+
const id = env[TERMINAL_ID];
|
|
82
|
+
if (id === undefined || id === "") return undefined;
|
|
83
|
+
const namespace = env[TERMINAL_NAMESPACE];
|
|
84
|
+
return { id, ...(namespace === undefined || namespace === "" ? {} : { namespace }) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The ops that act on the process behind a session.
|
|
88
|
+
*
|
|
89
|
+
* The subject is always resolved sid → pid here, at the moment of acting: a
|
|
90
|
+
* pid a caller asserted would be a weaker basis for killing something, and a
|
|
91
|
+
* pid resolved seconds ago may since have been recycled. What guards the
|
|
92
|
+
* recycling is the same check for all three ops — a pid whose process is no
|
|
93
|
+
* longer the harness is one this instance does not act on, and says so as the
|
|
94
|
+
* session not being there. */
|
|
95
|
+
export class SessionProcesses {
|
|
96
|
+
constructor(private readonly deps: ProcessDeps) {}
|
|
97
|
+
|
|
98
|
+
/** The pid behind a session, checked to still be that session's.
|
|
99
|
+
*
|
|
100
|
+
* A row this instance's config home does not hold is a session not found,
|
|
101
|
+
* whether it belongs to another config home or to nothing: this instance
|
|
102
|
+
* answers for one config home (M6), and a pid read from anywhere else is a
|
|
103
|
+
* number it has no business signalling. */
|
|
104
|
+
async pid(sid: Sid): Promise<number> {
|
|
105
|
+
const rows = this.deps.rows();
|
|
106
|
+
const row = rows.get(sid);
|
|
107
|
+
// A pid at or below 1 is refused before it reaches a signal: 0 addresses
|
|
108
|
+
// this process's own group and a negative number a whole group, so a
|
|
109
|
+
// corrupted row must not be able to reach either.
|
|
110
|
+
if (row === undefined || !Number.isInteger(row.pid) || row.pid <= 1) {
|
|
111
|
+
throw new OpError("session_not_found", `${sid} is no session of this instance`);
|
|
112
|
+
}
|
|
113
|
+
if (!(await this.isHarness(row.pid))) {
|
|
114
|
+
throw new OpError("session_not_found", `the process of ${sid} is gone`);
|
|
115
|
+
}
|
|
116
|
+
if (!(await this.isSameProcess(row.pid, row.started_at))) {
|
|
117
|
+
throw new OpError("session_not_found", `the process of ${sid} is gone`);
|
|
118
|
+
}
|
|
119
|
+
return row.pid;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** End the process behind a session. `terminated` reports whether it was
|
|
123
|
+
* seen to go, which is false rather than an error when the signals were
|
|
124
|
+
* delivered and the process was still there. */
|
|
125
|
+
async kill(sid: Sid, force = false): Promise<{ terminated: boolean }> {
|
|
126
|
+
const pid = await this.pid(sid);
|
|
127
|
+
const first = force ? "SIGKILL" : "SIGTERM";
|
|
128
|
+
if (this.send(pid, first)) return { terminated: true };
|
|
129
|
+
let waited = 0;
|
|
130
|
+
let repeated = force;
|
|
131
|
+
while (waited < GRACE_MS) {
|
|
132
|
+
await this.deps.sleep(LIVENESS_POLL_MS);
|
|
133
|
+
waited += LIVENESS_POLL_MS;
|
|
134
|
+
if (!this.deps.alive(pid)) return { terminated: true };
|
|
135
|
+
if (!repeated && waited >= SECOND_SIGNAL_AFTER_MS) {
|
|
136
|
+
repeated = true;
|
|
137
|
+
if (this.send(pid, "SIGTERM")) return { terminated: true };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { terminated: !this.deps.alive(pid) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The environment of the session's own process.
|
|
144
|
+
*
|
|
145
|
+
* Read from the resolved pid rather than from the connection the session
|
|
146
|
+
* speaks on: the helper holding that connection carries a different
|
|
147
|
+
* environment than the session itself. */
|
|
148
|
+
async environment(sid: Sid): Promise<{ pid: number; env: Record<string, string> }> {
|
|
149
|
+
const pid = await this.pid(sid);
|
|
150
|
+
let raw: string;
|
|
151
|
+
try {
|
|
152
|
+
raw = await this.deps.environment(pid);
|
|
153
|
+
} catch (cause) {
|
|
154
|
+
throw new OpError(
|
|
155
|
+
"not_found",
|
|
156
|
+
`the environment of ${sid} could not be read: ${String(cause)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return { pid, env: parseEnvironment(raw, this.deps.platform()) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The terminal a session runs in, as its own process names it.
|
|
163
|
+
*
|
|
164
|
+
* Read from the running process rather than remembered from when it started:
|
|
165
|
+
* resuming a session gives it a new process, in whatever terminal that one
|
|
166
|
+
* runs in. */
|
|
167
|
+
async terminal(sid: Sid): Promise<Terminal> {
|
|
168
|
+
const { env } = await this.environment(sid);
|
|
169
|
+
const terminal = terminalOf(env);
|
|
170
|
+
if (terminal === undefined) {
|
|
171
|
+
throw new OpError("not_found", `${sid} names no terminal to type into`);
|
|
172
|
+
}
|
|
173
|
+
return terminal;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Type into a session's terminal. */
|
|
177
|
+
async type(terminal: Terminal, keys: readonly string[]): Promise<void> {
|
|
178
|
+
const send = this.deps.type;
|
|
179
|
+
if (send === undefined) {
|
|
180
|
+
throw new OpError("capability_unavailable", "this instance types into no terminal");
|
|
181
|
+
}
|
|
182
|
+
await send(terminal, keys);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether the pid still belongs to the harness.
|
|
186
|
+
*
|
|
187
|
+
* The guard against a recycled pid, and the only thing standing between a
|
|
188
|
+
* stale row and a signal sent to an unrelated process. It compares what the
|
|
189
|
+
* process was launched as rather than searching its command line for a word:
|
|
190
|
+
* on a host running sessions, half the processes carry the harness's name
|
|
191
|
+
* somewhere in their arguments, including this instance. */
|
|
192
|
+
private async isHarness(pid: number): Promise<boolean> {
|
|
193
|
+
let command: string;
|
|
194
|
+
try {
|
|
195
|
+
command = await this.deps.command(pid);
|
|
196
|
+
} catch {
|
|
197
|
+
// `ps` failing includes the pid being gone, which is the same outcome for
|
|
198
|
+
// every caller: there is no process of this session to act on.
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return executable(command) === HARNESS;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Whether the process running under the pid now is the one the row was
|
|
205
|
+
* written for.
|
|
206
|
+
*
|
|
207
|
+
* The second half of the recycling guard, and the half that catches what
|
|
208
|
+
* argv0 cannot: a pid recycled onto another session of the same harness is a
|
|
209
|
+
* process the argv0 check accepts. What separates them is when they started
|
|
210
|
+
* — a recycled pid belongs to a process that began after the row was
|
|
211
|
+
* written, since the pid was not free until the row's own process had
|
|
212
|
+
* exited.
|
|
213
|
+
*
|
|
214
|
+
* A start time the host stated in a form this instance could not read leaves
|
|
215
|
+
* the pid on the argv0 check alone. Refusing instead would make the ops
|
|
216
|
+
* unusable on such a host, which is a certain loss against the one this
|
|
217
|
+
* guards. */
|
|
218
|
+
private async isSameProcess(pid: number, startedAt: Timestamp): Promise<boolean> {
|
|
219
|
+
let started: Timestamp | undefined;
|
|
220
|
+
try {
|
|
221
|
+
started = await this.deps.started(pid);
|
|
222
|
+
} catch {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (started === undefined) return true;
|
|
226
|
+
const after = started - startedAt;
|
|
227
|
+
return after <= STARTED_AFTER_TOLERANCE_MS && -after <= STARTED_BEFORE_TOLERANCE_MS;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Send one signal. Answers whether the process was already gone, which is
|
|
231
|
+
* what the caller wanted; any other failure is not this op's to interpret. */
|
|
232
|
+
private send(pid: number, signal: "SIGTERM" | "SIGKILL"): boolean {
|
|
233
|
+
try {
|
|
234
|
+
this.deps.signal(pid, signal);
|
|
235
|
+
return false;
|
|
236
|
+
} catch (cause) {
|
|
237
|
+
if (isGone(cause)) return true;
|
|
238
|
+
throw cause;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The command a session runs as. */
|
|
244
|
+
const HARNESS = "claude";
|
|
245
|
+
|
|
246
|
+
/** The last segment of the first word of a command line: what the process was
|
|
247
|
+
* launched as, however it was found on the path. */
|
|
248
|
+
function executable(command: string): string {
|
|
249
|
+
const argv0 = command.trimStart().split(/\s/, 1)[0] ?? "";
|
|
250
|
+
return argv0.slice(argv0.lastIndexOf("/") + 1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The pid is gone. The one signalling failure that means the caller's goal
|
|
254
|
+
* was already met — every other one (notably being refused permission on a
|
|
255
|
+
* process that is demonstrably there) must not read as a confirmed kill. */
|
|
256
|
+
function isGone(cause: unknown): boolean {
|
|
257
|
+
return (
|
|
258
|
+
typeof cause === "object" && cause !== null && (cause as { code?: unknown }).code === "ESRCH"
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A process environment, in the shape its platform states it.
|
|
263
|
+
*
|
|
264
|
+
* Linux writes NUL-separated `KEY=VALUE` records, which is unambiguous: a
|
|
265
|
+
* value holding spaces, newlines or `=` survives whole. macOS prints the
|
|
266
|
+
* environment space-separated after the command line with no quoting at all,
|
|
267
|
+
* so a value holding a space cannot be told from the next variable by
|
|
268
|
+
* splitting alone — a token is read as a new variable only when it looks like
|
|
269
|
+
* an assignment, and anything else continues the value before it. That
|
|
270
|
+
* reconstructs spaced values except where one contains a token that itself
|
|
271
|
+
* reads as an assignment, which is the format's own ambiguity rather than a
|
|
272
|
+
* choice made here. */
|
|
273
|
+
export function parseEnvironment(raw: string, platform: NodeJS.Platform): Record<string, string> {
|
|
274
|
+
return platform === "linux" ? parseRecords(raw) : parseColumns(raw);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** A variable name as POSIX states one, anchored to a whole token's key part. */
|
|
278
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
279
|
+
|
|
280
|
+
function parseRecords(raw: string): Record<string, string> {
|
|
281
|
+
const env: Record<string, string> = {};
|
|
282
|
+
for (const record of raw.split("\0")) {
|
|
283
|
+
const at = record.indexOf("=");
|
|
284
|
+
// A record with no name is not an assignment; skipping beats inventing an
|
|
285
|
+
// entry with an empty name.
|
|
286
|
+
if (at <= 0) continue;
|
|
287
|
+
env[record.slice(0, at)] = record.slice(at + 1);
|
|
288
|
+
}
|
|
289
|
+
return env;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function parseColumns(raw: string): Record<string, string> {
|
|
293
|
+
const env: Record<string, string> = {};
|
|
294
|
+
let key: string | undefined;
|
|
295
|
+
for (const token of raw.trim().split(/\s+/)) {
|
|
296
|
+
if (ASSIGNMENT.test(token)) {
|
|
297
|
+
const at = token.indexOf("=");
|
|
298
|
+
key = token.slice(0, at);
|
|
299
|
+
env[key] = token.slice(at + 1);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Everything before the first assignment is the command line, and
|
|
303
|
+
// everything after one that is not itself an assignment is the rest of a
|
|
304
|
+
// value `ps` collapsed a separator inside.
|
|
305
|
+
if (key !== undefined) env[key] += ` ${token}`;
|
|
306
|
+
}
|
|
307
|
+
return env;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Run one child and take its output.
|
|
311
|
+
*
|
|
312
|
+
* Both pipes are drained together: a child writing more than a pipe holds to
|
|
313
|
+
* one nobody reads never exits, and an environment is easily that large. */
|
|
314
|
+
export async function run(argv: string[], timeoutMs = CHILD_TIMEOUT_MS): Promise<string> {
|
|
315
|
+
const child = Bun.spawn(argv, {
|
|
316
|
+
env: process.env,
|
|
317
|
+
stdin: "ignore",
|
|
318
|
+
stdout: "pipe",
|
|
319
|
+
stderr: "pipe",
|
|
320
|
+
timeout: timeoutMs,
|
|
321
|
+
killSignal: "SIGKILL",
|
|
322
|
+
});
|
|
323
|
+
const [code, out, error] = await Promise.all([
|
|
324
|
+
child.exited,
|
|
325
|
+
new Response(child.stdout).text(),
|
|
326
|
+
new Response(child.stderr).text(),
|
|
327
|
+
]);
|
|
328
|
+
if (code !== 0) throw new Error(`${argv[0]} exited ${code}: ${error.trim().slice(0, 200)}`);
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The environment of one process, as this host exposes it: a file on Linux,
|
|
333
|
+
* and only `ps` on macOS. */
|
|
334
|
+
function hostEnvironment(pid: number): Promise<string> {
|
|
335
|
+
return process.platform === "linux"
|
|
336
|
+
? Bun.file(`/proc/${pid}/environ`).text()
|
|
337
|
+
: run(["ps", "eww", "-p", String(pid), "-o", "command="]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** When one process started, as this host states it.
|
|
341
|
+
*
|
|
342
|
+
* Asked for as the time it has been running rather than as the instant it
|
|
343
|
+
* began: `ps` prints an instant in local time with no zone on it, which a
|
|
344
|
+
* reader in another zone — a daemon started under one, a test run under UTC —
|
|
345
|
+
* turns into an instant hours away. Elapsed time carries no zone at all.
|
|
346
|
+
*
|
|
347
|
+
* The format is `[[dd-]hh:]mm:ss`, and its resolution is the second, which is
|
|
348
|
+
* why the guard that compares it allows for one. */
|
|
349
|
+
async function hostStarted(pid: number): Promise<Timestamp | undefined> {
|
|
350
|
+
const elapsed = elapsedSeconds((await run(["ps", "-p", String(pid), "-o", "etime="])).trim());
|
|
351
|
+
return elapsed === undefined ? undefined : Date.now() - elapsed * 1000;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** `[[dd-]hh:]mm:ss` in seconds. Undefined for anything else, which is a host
|
|
355
|
+
* whose `ps` states elapsed time in some other form. */
|
|
356
|
+
export function elapsedSeconds(etime: string): number | undefined {
|
|
357
|
+
const [days, clock] = etime.includes("-") ? etime.split("-", 2) : [undefined, etime];
|
|
358
|
+
const parts = (clock ?? "").split(":");
|
|
359
|
+
if (parts.length < 2 || parts.length > 3) return undefined;
|
|
360
|
+
const numbers = [...(days === undefined ? [] : [days]), ...parts].map(Number);
|
|
361
|
+
if (numbers.some((part) => !Number.isInteger(part) || part < 0)) return undefined;
|
|
362
|
+
// Seconds are last whatever was stated before them, so the units are read
|
|
363
|
+
// from the end: seconds, minutes, hours, days.
|
|
364
|
+
return numbers
|
|
365
|
+
.reverse()
|
|
366
|
+
.reduce((total, part, at) => total + part * [1, 60, 3600, 86_400][at]!, 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The reader the terminal cache is filled through, as this host provides it. */
|
|
370
|
+
export function hostTerminalReader(): TerminalReader {
|
|
371
|
+
return async (pid) => terminalOf(parseEnvironment(await hostEnvironment(pid), process.platform));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The effects as this host provides them. */
|
|
375
|
+
export function hostProcessDeps(
|
|
376
|
+
rows: () => ReadonlyMap<Sid, AgentInfo>,
|
|
377
|
+
terminalCommand?: string,
|
|
378
|
+
): ProcessDeps {
|
|
379
|
+
return {
|
|
380
|
+
rows,
|
|
381
|
+
command: (pid) => run(["ps", "-p", String(pid), "-o", "command="]),
|
|
382
|
+
environment: hostEnvironment,
|
|
383
|
+
started: hostStarted,
|
|
384
|
+
signal: (pid, signal) => {
|
|
385
|
+
process.kill(pid, signal);
|
|
386
|
+
},
|
|
387
|
+
alive: (pid) => {
|
|
388
|
+
try {
|
|
389
|
+
process.kill(pid, 0);
|
|
390
|
+
return true;
|
|
391
|
+
} catch (cause) {
|
|
392
|
+
// Being refused permission means the process is there and not ours to
|
|
393
|
+
// signal, which is not the same as gone.
|
|
394
|
+
return !isGone(cause);
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
398
|
+
platform: () => process.platform,
|
|
399
|
+
...(terminalCommand === undefined
|
|
400
|
+
? {}
|
|
401
|
+
: {
|
|
402
|
+
type: async (terminal: Terminal, keys: readonly string[]) => {
|
|
403
|
+
await run([
|
|
404
|
+
terminalCommand,
|
|
405
|
+
"input",
|
|
406
|
+
...(terminal.namespace === undefined ? [] : ["--namespace", terminal.namespace]),
|
|
407
|
+
terminal.id,
|
|
408
|
+
...keys,
|
|
409
|
+
]);
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
};
|
|
413
|
+
}
|