pi-open-deskos 0.2.1 → 0.4.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/README.md +88 -19
- package/README.zh-CN.md +76 -19
- package/fixtures/control-v2.json +2 -2
- package/package.json +7 -6
- package/session-control +38 -0
- package/src/config.ts +135 -15
- package/src/console-client.ts +47 -21
- package/src/console-extension.ts +84 -43
- package/src/control-transport.ts +3 -3
- package/src/desk-set.ts +69 -0
- package/src/events.ts +77 -19
- package/src/index.ts +172 -29
- package/src/processes.ts +117 -13
- package/src/reporter.ts +117 -39
- package/src/session-host-client.mjs +274 -0
- package/src/session-host.ts +632 -0
- package/src/session-replay.ts +38 -0
- package/src/transport.ts +34 -11
- package/src/types.ts +18 -9
package/src/index.ts
CHANGED
|
@@ -1,79 +1,222 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { readOdkLinkConfigs } from "./config.ts";
|
|
3
4
|
import { registerConsoleExtension } from "./console-extension.ts";
|
|
4
5
|
import { createControlTcpTransport } from "./control-transport.ts";
|
|
6
|
+
import { aggregateDeskSnapshots, selectConsoleDesk } from "./desk-set.ts";
|
|
5
7
|
import { eventsFromMessage, promptFromMessage } from "./events.ts";
|
|
6
8
|
import { scanDirectorySessions, SessionDiscovery } from "./discovery.ts";
|
|
7
|
-
import { DeskReporter } from "./reporter.ts";
|
|
9
|
+
import { DeskReporter, type OdkLinkSnapshot } from "./reporter.ts";
|
|
10
|
+
import { SessionHost } from "./session-host.ts";
|
|
11
|
+
import { CurrentSessionReplay } from "./session-replay.ts";
|
|
8
12
|
import { createTcpTransport } from "./transport.ts";
|
|
9
13
|
|
|
10
|
-
const CONFIG_HINT = "set
|
|
14
|
+
const CONFIG_HINT = "set ODK_LINK_ADDRESS and ODK_LINK_TOKEN, or list desks in ~/.config/odk/desks.json, to report this machine";
|
|
15
|
+
/** Read only a bounded tail before it enters the reporter's own 300-event/1MiB retention pipeline. */
|
|
16
|
+
const MAX_DURABLE_SESSION_BYTES = 4 * 1024 * 1024;
|
|
11
17
|
|
|
12
18
|
function workspaceName(cwd: string): string {
|
|
13
19
|
const parts = cwd.split(/[/\\]/).filter((part) => part.length > 0);
|
|
14
20
|
return parts.length > 0 ? (parts[parts.length - 1] ?? cwd) : cwd;
|
|
15
21
|
}
|
|
16
22
|
|
|
23
|
+
/**
|
|
24
|
+
* What a running session is doing, in the card's own vocabulary: the tool and the
|
|
25
|
+
* one argument that says what it is working on, as `read: src/example.js` does.
|
|
26
|
+
* A tool whose arguments carry no such line is named and nothing more, so no
|
|
27
|
+
* argument is echoed beyond the summary the desk shows.
|
|
28
|
+
*/
|
|
29
|
+
function toolSummary(event: { toolName?: unknown; args?: unknown }): string {
|
|
30
|
+
const name = typeof event.toolName === "string" && event.toolName.length > 0 ? event.toolName : "tool";
|
|
31
|
+
const args = (event.args ?? {}) as Record<string, unknown>;
|
|
32
|
+
const hint = ["command", "path", "file", "pattern", "url", "query"]
|
|
33
|
+
.map((key) => args[key])
|
|
34
|
+
.find((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
35
|
+
return hint === undefined ? name : `${name}: ${hint.trim()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The current session manager points at its own trusted durable log. Read its
|
|
40
|
+
* bounded tail in chronological order; other sessions never enter this path.
|
|
41
|
+
*/
|
|
42
|
+
async function durableSessionEvents(sessionFile: string | undefined): Promise<ReturnType<typeof eventsFromMessage>> {
|
|
43
|
+
if (!sessionFile) return [];
|
|
44
|
+
try {
|
|
45
|
+
const handle = await open(sessionFile, "r");
|
|
46
|
+
try {
|
|
47
|
+
const stat = await handle.stat();
|
|
48
|
+
if (!stat.isFile() || stat.size <= 0) return [];
|
|
49
|
+
const bytes = Math.min(stat.size, MAX_DURABLE_SESSION_BYTES);
|
|
50
|
+
const tail = Buffer.alloc(bytes);
|
|
51
|
+
await handle.read(tail, 0, bytes, stat.size - bytes);
|
|
52
|
+
const text = tail.toString("utf8");
|
|
53
|
+
const lines = text.split("\n");
|
|
54
|
+
// A writer can have produced valid JSON before its terminating newline.
|
|
55
|
+
// It is still an incomplete append and must wait for message_end instead
|
|
56
|
+
// of being replayed now and then sent a second time live.
|
|
57
|
+
if (!text.endsWith("\n")) lines.pop();
|
|
58
|
+
// A bounded tail can begin mid-record; that first fragment is never an entry.
|
|
59
|
+
if (stat.size > bytes) lines.shift();
|
|
60
|
+
return lines.flatMap((line) => {
|
|
61
|
+
if (!line.trim()) return [];
|
|
62
|
+
try {
|
|
63
|
+
const entry = JSON.parse(line) as { type?: unknown; id?: unknown; message?: unknown };
|
|
64
|
+
return entry.type === "message" && entry.message !== undefined ? eventsFromMessage(entry.message, entry.id) : [];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
} finally {
|
|
70
|
+
await handle.close();
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
17
77
|
/**
|
|
18
78
|
* Report this machine's Pi sessions and, with an independent credential, drive
|
|
19
|
-
* Hosted Pi sessions over a separate
|
|
79
|
+
* Hosted Pi sessions over a separate Open DeskOS Link v2 control connection.
|
|
20
80
|
*/
|
|
21
81
|
export default function (pi: ExtensionAPI): void {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
82
|
+
const { configs, refusals } = readOdkLinkConfigs();
|
|
83
|
+
const reporters = configs.map((config) => new DeskReporter({ config, createTransport: createTcpTransport }));
|
|
84
|
+
const endpoints = configs.map((config) => `${config.host}:${config.port}`);
|
|
85
|
+
// This is one machine reporting, however many desks listen: discovery runs
|
|
86
|
+
// once and every desk receives the same session, status, and event updates,
|
|
87
|
+
// so one desk being unreachable never stops the others.
|
|
88
|
+
const each = (run: (reporter: DeskReporter) => void): void => {
|
|
89
|
+
for (const reporter of reporters) run(reporter);
|
|
90
|
+
};
|
|
91
|
+
const aggregate = (): OdkLinkSnapshot | null =>
|
|
92
|
+
aggregateDeskSnapshots(reporters.map((reporter, index) => ({ ...reporter.snapshot(), endpoint: endpoints[index] ?? "desk" })));
|
|
93
|
+
const discovery = reporters.length === 0 ? null : new SessionDiscovery({
|
|
25
94
|
discover: () => scanDirectorySessions(),
|
|
26
|
-
onSnapshot: (sessions) => reporter.replaceDiscoveredSessions(sessions),
|
|
95
|
+
onSnapshot: (sessions) => each((reporter) => reporter.replaceDiscoveredSessions(sessions)),
|
|
27
96
|
});
|
|
97
|
+
const replay = reporters.length === 0 ? null : new CurrentSessionReplay(durableSessionEvents);
|
|
98
|
+
// A desk's voice agent can drive the session this process is, from inside this process: the
|
|
99
|
+
// same lifecycle events, a private socket, and no new process or credential. It is created
|
|
100
|
+
// here and bound only from session_start, so loading the extension binds nothing.
|
|
101
|
+
//
|
|
102
|
+
// It is opt-in, because an endpoint that lets something else hand this session work is a
|
|
103
|
+
// capability rather than a default: a machine that wants one names the directory it belongs
|
|
104
|
+
// in with ODK_SESSION_HOST_SOCKET, and a machine that does not is never reachable.
|
|
105
|
+
const sessionHost = new SessionHost({ pi });
|
|
106
|
+
const sessionHostEnabled = (process.env.ODK_SESSION_HOST_SOCKET ?? "").length > 0;
|
|
28
107
|
let currentSessionId = "";
|
|
108
|
+
let sessionGeneration = 0;
|
|
109
|
+
let pendingTurn: { generation: number; sessionId: string; previousMessageId: string | undefined } | null = null;
|
|
110
|
+
const lastMessage = (ctx: ExtensionContext) => ctx.sessionManager.getBranch().filter(entry => entry.type === "message").at(-1);
|
|
111
|
+
let hostRefusal = "";
|
|
29
112
|
|
|
30
|
-
if (
|
|
31
|
-
function beginSession(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]):
|
|
113
|
+
if (reporters.length > 0 || sessionHostEnabled) {
|
|
114
|
+
function beginSession(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): string {
|
|
32
115
|
if (currentSessionId.length > 0 && currentSessionId !== ctx.sessionManager.getSessionId()) {
|
|
33
|
-
reporter
|
|
116
|
+
each((reporter) => reporter.markStatus(currentSessionId, "exited"));
|
|
34
117
|
}
|
|
118
|
+
pendingTurn = null;
|
|
35
119
|
currentSessionId = ctx.sessionManager.getSessionId();
|
|
36
120
|
const name = ctx.sessionManager.getSessionName();
|
|
37
121
|
const timestamp = ctx.sessionManager.getHeader()?.timestamp;
|
|
38
122
|
const startedAt = timestamp ? Date.parse(timestamp) : Number.NaN;
|
|
39
|
-
reporter
|
|
123
|
+
each((reporter) => reporter.recordSession({
|
|
40
124
|
sessionId: currentSessionId,
|
|
41
125
|
...(name === undefined ? {} : { name }),
|
|
42
126
|
cwd: ctx.cwd,
|
|
43
127
|
workspaceName: workspaceName(ctx.cwd),
|
|
44
128
|
status: ctx.isIdle() ? "settled" : "running",
|
|
45
129
|
startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
|
|
46
|
-
});
|
|
130
|
+
}));
|
|
131
|
+
return currentSessionId;
|
|
47
132
|
}
|
|
48
133
|
|
|
49
|
-
pi.on("session_start", (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
134
|
+
pi.on("session_start", async (event, ctx) => {
|
|
135
|
+
const generation = ++sessionGeneration;
|
|
136
|
+
const sessionId = beginSession(ctx);
|
|
137
|
+
each((reporter) => reporter.start());
|
|
138
|
+
discovery?.start();
|
|
139
|
+
// A resumed/reloaded Pi can have a durable JSONL history that is longer
|
|
140
|
+
// than its in-memory branch. Start the gate for every session; only the
|
|
141
|
+
// resumed/reloaded/forked cases provide a durable tail. New sessions still
|
|
142
|
+
// queue their first message_end event behind this empty replay boundary.
|
|
143
|
+
const replayFile = ["resume", "reload", "fork"].includes((event as { reason?: string }).reason ?? "")
|
|
144
|
+
? ctx.sessionManager.getSessionFile?.()
|
|
145
|
+
: undefined;
|
|
146
|
+
await replay?.start(sessionId, replayFile, (id, events) => each((reporter) => reporter.recordEvents(id, events)));
|
|
147
|
+
// A reload replaces the previous endpoint cleanly, so a desk never finds a
|
|
148
|
+
// half-replaced one, and a machine that cannot spare an endpoint still reports.
|
|
149
|
+
if (generation !== sessionGeneration) return;
|
|
150
|
+
if (sessionHostEnabled) {
|
|
151
|
+
const started = await sessionHost.start(ctx);
|
|
152
|
+
if (generation !== sessionGeneration) return;
|
|
153
|
+
const previous = refusals.indexOf(hostRefusal);
|
|
154
|
+
if (previous >= 0) refusals.splice(previous, 1);
|
|
155
|
+
hostRefusal = started.reason ? `current-session endpoint: ${started.reason}` : "";
|
|
156
|
+
if (hostRefusal) refusals.push(hostRefusal);
|
|
157
|
+
}
|
|
53
158
|
});
|
|
54
159
|
pi.on("session_info_changed", (event) => {
|
|
55
|
-
if (currentSessionId.length > 0) reporter.renameSession(currentSessionId, event.name);
|
|
160
|
+
if (currentSessionId.length > 0) each((reporter) => reporter.renameSession(currentSessionId, event.name));
|
|
161
|
+
});
|
|
162
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
163
|
+
pendingTurn = { generation: sessionGeneration, sessionId: ctx.sessionManager.getSessionId(), previousMessageId: lastMessage(ctx)?.id };
|
|
164
|
+
sessionHost.markRunning();
|
|
165
|
+
each((reporter) => reporter.markStatus(currentSessionId, "running"));
|
|
166
|
+
});
|
|
167
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
168
|
+
const turn = pendingTurn;
|
|
169
|
+
pendingTurn = null;
|
|
170
|
+
sessionHost.markSettled();
|
|
171
|
+
each((reporter) => reporter.markStatus(currentSessionId, "settled"));
|
|
172
|
+
if (!turn || event.aborted || turn.generation !== sessionGeneration || turn.sessionId !== currentSessionId
|
|
173
|
+
|| ctx.sessionManager.getSessionId() !== currentSessionId) return;
|
|
174
|
+
// message_end runs before appendMessage. Only settled exposes this turn's
|
|
175
|
+
// persisted identity, never the preceding leaf or a text-derived surrogate.
|
|
176
|
+
const entry = lastMessage(ctx);
|
|
177
|
+
if (!entry || entry.id === turn.previousMessageId || entry.message.role !== "assistant" || entry.message.stopReason !== "stop") return;
|
|
178
|
+
const completed = eventsFromMessage(entry.message, entry.id).filter(value => value.kind === "assistant");
|
|
179
|
+
replay?.append(currentSessionId, completed, (id, fresh) => each(reporter => reporter.recordEvents(id, fresh)));
|
|
180
|
+
});
|
|
181
|
+
// A turn runs several tools before its message ends, and until that message
|
|
182
|
+
// the card would keep showing the prompt that started the turn. The tool that
|
|
183
|
+
// is running now is the newest thing the session is doing, so it is what the
|
|
184
|
+
// card states.
|
|
185
|
+
pi.on("tool_execution_start", (event) => {
|
|
186
|
+
if (currentSessionId.length === 0) return;
|
|
187
|
+
const activity = toolSummary(event);
|
|
188
|
+
sessionHost.observeActivity(activity);
|
|
189
|
+
each((reporter) => reporter.recordActivity(currentSessionId, activity));
|
|
56
190
|
});
|
|
57
|
-
pi.on("agent_start", () => reporter.markStatus(currentSessionId, "running"));
|
|
58
|
-
pi.on("agent_settled", () => reporter.markStatus(currentSessionId, "settled"));
|
|
59
191
|
pi.on("message_end", (event) => {
|
|
192
|
+
sessionHost.observeMessage(event.message);
|
|
60
193
|
if (currentSessionId.length === 0) return;
|
|
61
194
|
const prompt = promptFromMessage(event.message);
|
|
62
|
-
if (prompt.length > 0) reporter.recordSession({ sessionId: currentSessionId, latestGoal: prompt });
|
|
63
|
-
|
|
195
|
+
if (prompt.length > 0) each((reporter) => reporter.recordSession({ sessionId: currentSessionId, latestGoal: prompt }));
|
|
196
|
+
const events = eventsFromMessage(event.message);
|
|
197
|
+
replay?.append(currentSessionId, events, (id, fresh) => each((reporter) => reporter.recordEvents(id, fresh)));
|
|
64
198
|
});
|
|
65
|
-
pi.on("session_shutdown", () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
199
|
+
pi.on("session_shutdown", async () => {
|
|
200
|
+
sessionGeneration += 1;
|
|
201
|
+
pendingTurn = null;
|
|
202
|
+
replay?.invalidate();
|
|
203
|
+
discovery?.stop();
|
|
204
|
+
each((reporter) => reporter.markStatus(currentSessionId, "exited"));
|
|
205
|
+
currentSessionId = "";
|
|
206
|
+
each((reporter) => reporter.disconnect());
|
|
207
|
+
await sessionHost.close();
|
|
69
208
|
});
|
|
70
209
|
}
|
|
71
210
|
|
|
72
211
|
registerConsoleExtension({
|
|
73
212
|
pi,
|
|
74
|
-
|
|
213
|
+
// The Console drives one desk: the one that issued a control credential,
|
|
214
|
+
// unless the operator named the desk they mean.
|
|
215
|
+
config: selectConsoleDesk(configs, process.env.ODK_LINK_CONSOLE_DESK ?? ""),
|
|
216
|
+
desks: configs,
|
|
217
|
+
refusals,
|
|
75
218
|
createControlTransport: createControlTcpTransport,
|
|
76
|
-
reporterSnapshot:
|
|
219
|
+
reporterSnapshot: aggregate,
|
|
77
220
|
configHint: CONFIG_HINT,
|
|
78
221
|
});
|
|
79
222
|
}
|
package/src/processes.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
2
|
|
|
4
3
|
/** A Pi process identity from one bounded, read-only OS process snapshot. */
|
|
5
4
|
export interface PiProcess {
|
|
@@ -9,24 +8,85 @@ export interface PiProcess {
|
|
|
9
8
|
|
|
10
9
|
const MAX_PROCESS_BYTES = 1024 * 1024;
|
|
11
10
|
const PROCESS_TIMEOUT_MS = 2000;
|
|
11
|
+
const WINDOWS_PROCESS_SCRIPT = [
|
|
12
|
+
'$ErrorActionPreference = "Stop";',
|
|
13
|
+
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);',
|
|
14
|
+
'Get-CimInstance Win32_Process |',
|
|
15
|
+
'Select-Object ProcessId, Name, CommandLine,',
|
|
16
|
+
"@{n='startedAtMs'; e={ [int64]([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() }} |",
|
|
17
|
+
'ConvertTo-Json -Compress -Depth 3',
|
|
18
|
+
].join(" ");
|
|
19
|
+
|
|
20
|
+
function executableName(value: string): string {
|
|
21
|
+
return (value.split(/[/\\]/).at(-1) ?? "").replace(/\.exe$/i, "");
|
|
22
|
+
}
|
|
12
23
|
|
|
13
24
|
function piExecutable(value: string): boolean {
|
|
14
|
-
return /^pi(?:-rpc)?(?:\.[cm]?js)?$/i.test(
|
|
25
|
+
return /^pi(?:-rpc)?(?:\.[cm]?js)?$/i.test(executableName(value));
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
function isPiInvocation(comm: string, args: string): boolean {
|
|
28
|
+
function isPiInvocation(comm: string, args: string, suppliedTokens?: string[]): boolean {
|
|
18
29
|
if (piExecutable(comm)) return true;
|
|
19
|
-
const tokens = args.trim().split(/\s+/);
|
|
30
|
+
const tokens = suppliedTokens ?? args.trim().split(/\s+/);
|
|
20
31
|
const executable = tokens[0] ?? "";
|
|
21
32
|
if (piExecutable(executable)) return true;
|
|
22
33
|
// Inspect the executable/script positions only, never arbitrary arguments
|
|
23
34
|
// (a grep or shell command mentioning pi is not evidence of a Pi process).
|
|
24
|
-
if (!["node", "bun", "deno"].includes(
|
|
35
|
+
if (!["node", "bun", "deno"].includes(executableName(executable).toLowerCase())) return false;
|
|
25
36
|
const script = tokens[1] ?? "";
|
|
26
37
|
return piExecutable(script)
|
|
27
38
|
|| /[/\\]@earendil-works[/\\]pi-coding-agent[/\\]dist[/\\](?:bundle[/\\])?(?:cli|rpc-entry)\.js$/.test(script);
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
/** Windows quoting keeps paths with spaces together; backslashes escape quotes only. */
|
|
42
|
+
function windowsCommandTokens(value: string): string[] {
|
|
43
|
+
const tokens: string[] = [];
|
|
44
|
+
let token = "", quoted = false, started = false;
|
|
45
|
+
for (let index = 0; index < value.length;) {
|
|
46
|
+
const character = value[index]!;
|
|
47
|
+
if (character === "\\") {
|
|
48
|
+
let count = 0;
|
|
49
|
+
while (value[index] === "\\") { count += 1; index += 1; }
|
|
50
|
+
if (value[index] === '"') {
|
|
51
|
+
token += "\\".repeat(Math.floor(count / 2));
|
|
52
|
+
if (count % 2) token += '"';
|
|
53
|
+
else quoted = !quoted;
|
|
54
|
+
index += 1;
|
|
55
|
+
} else token += "\\".repeat(count);
|
|
56
|
+
started = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (character === '"') { quoted = !quoted; started = true; index += 1; continue; }
|
|
60
|
+
if (!quoted && /[ \t]/.test(character)) {
|
|
61
|
+
if (started) tokens.push(token);
|
|
62
|
+
token = ""; started = false; index += 1; continue;
|
|
63
|
+
}
|
|
64
|
+
token += character; started = true; index += 1;
|
|
65
|
+
}
|
|
66
|
+
if (quoted) return [];
|
|
67
|
+
if (started) tokens.push(token);
|
|
68
|
+
return tokens;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read only the projected WMI identity, never disclose command lines. */
|
|
72
|
+
export function parseWindowsPiProcesses(output: string, now: number): PiProcess[] {
|
|
73
|
+
if (Buffer.byteLength(output) > MAX_PROCESS_BYTES) return [];
|
|
74
|
+
let decoded: unknown;
|
|
75
|
+
try { decoded = JSON.parse(output.replace(/^\uFEFF/, "")); } catch { return []; }
|
|
76
|
+
const processes: PiProcess[] = [];
|
|
77
|
+
for (const item of Array.isArray(decoded) ? decoded : [decoded]) {
|
|
78
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
79
|
+
const row = item as Record<string, unknown>;
|
|
80
|
+
const pid = row.ProcessId, startedAt = row.startedAtMs;
|
|
81
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff
|
|
82
|
+
|| typeof startedAt !== "number" || !Number.isSafeInteger(startedAt) || startedAt <= 0 || startedAt > now) continue;
|
|
83
|
+
const command = typeof row.CommandLine === "string" ? row.CommandLine : "";
|
|
84
|
+
const name = typeof row.Name === "string" ? row.Name : "";
|
|
85
|
+
if (isPiInvocation(name, command, windowsCommandTokens(command))) processes.push({ pid, startedAt });
|
|
86
|
+
}
|
|
87
|
+
return processes;
|
|
88
|
+
}
|
|
89
|
+
|
|
30
90
|
function elapsedSeconds(value: string): number | null {
|
|
31
91
|
if (/^\d+$/.test(value)) return Number(value);
|
|
32
92
|
const match = value.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
@@ -48,14 +108,58 @@ export function parsePiProcesses(output: string, now: number): PiProcess[] {
|
|
|
48
108
|
}
|
|
49
109
|
|
|
50
110
|
/** Never log command lines; discard the bounded process table after parsing. */
|
|
51
|
-
export function
|
|
52
|
-
|
|
53
|
-
|
|
111
|
+
export function readProcessSnapshot(command: string, args: string[], options: {
|
|
112
|
+
timeoutMs?: number;
|
|
113
|
+
execute?: typeof execFile;
|
|
114
|
+
} = {}): Promise<string> {
|
|
115
|
+
const timeoutMs = options.timeoutMs ?? PROCESS_TIMEOUT_MS;
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let settled = false;
|
|
118
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
119
|
+
const finish = (error: Error | null, output = ""): void => {
|
|
120
|
+
if (settled) return;
|
|
121
|
+
settled = true;
|
|
122
|
+
if (deadline !== undefined) clearTimeout(deadline);
|
|
123
|
+
if (error) reject(error);
|
|
124
|
+
else resolve(output);
|
|
125
|
+
};
|
|
126
|
+
// execFile's timeout waits for child close; a stuck OS query may never close.
|
|
127
|
+
const child = (options.execute ?? execFile)(command, args, {
|
|
128
|
+
encoding: "utf8", maxBuffer: MAX_PROCESS_BYTES, timeout: timeoutMs, windowsHide: true,
|
|
129
|
+
}, (error, stdout) => finish(error, stdout));
|
|
130
|
+
if (settled) return;
|
|
131
|
+
deadline = setTimeout(() => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
child.kill("SIGKILL");
|
|
134
|
+
child.stdout?.destroy();
|
|
135
|
+
child.stderr?.destroy();
|
|
136
|
+
child.unref();
|
|
137
|
+
finish(new Error("process snapshot deadline exceeded"));
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
deadline.unref?.();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Return an unavailable inventory on every bounded query failure. */
|
|
144
|
+
export async function listPiProcesses(options: {
|
|
145
|
+
platform?: NodeJS.Platform;
|
|
146
|
+
now?: () => number;
|
|
147
|
+
readSnapshot?: (command: string, args: string[]) => Promise<string>;
|
|
148
|
+
} = {}): Promise<PiProcess[]> {
|
|
149
|
+
const platform = options.platform ?? process.platform;
|
|
150
|
+
const now = (options.now ?? Date.now)();
|
|
151
|
+
const command = platform === "win32" ? "powershell.exe" : "ps";
|
|
152
|
+
const args = platform === "win32"
|
|
153
|
+
? ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_PROCESS_SCRIPT]
|
|
154
|
+
: platform === "darwin"
|
|
54
155
|
? ["-axo", "pid=,etime=,stat=,comm=,args="]
|
|
55
156
|
: ["-eo", "pid=,etimes=,stat=,comm=,args="];
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
157
|
+
const read = options.readSnapshot ?? ((executable, arguments_) => readProcessSnapshot(executable, arguments_, {
|
|
158
|
+
timeoutMs: platform === "win32" ? 5000 : PROCESS_TIMEOUT_MS,
|
|
159
|
+
}));
|
|
160
|
+
try {
|
|
161
|
+
const output = await read(command, args);
|
|
162
|
+
if (Buffer.byteLength(output) > MAX_PROCESS_BYTES) return [];
|
|
163
|
+
return platform === "win32" ? parseWindowsPiProcesses(output, now) : parsePiProcesses(output, now);
|
|
164
|
+
} catch { return []; }
|
|
61
165
|
}
|