@saifymatteo/pi-extensions 0.1.4 → 0.1.6
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 +34 -0
- package/package.json +2 -1
- package/packages/pi-bg-bash/AGENTS.md +29 -0
- package/packages/pi-bg-bash/CHANGELOG.md +25 -0
- package/packages/pi-bg-bash/package.json +2 -1
- package/packages/pi-bg-bash/src/index.ts +189 -99
- package/packages/pi-bg-bash/src/lib/output.ts +31 -0
- package/packages/pi-bg-bash/src/lib/panel.ts +155 -0
- package/packages/pi-bg-bash/src/lib/process-control.ts +221 -0
- package/packages/pi-bg-bash/src/lib/windows-console.ts +169 -0
- package/packages/pi-bg-bash/test/job-controls.test.mjs +347 -0
- package/packages/pi-bg-bash/test/native-process-control.test.mjs +126 -0
- package/packages/pi-bg-bash/test/output.test.mjs +28 -0
- package/packages/pi-bg-bash/test/panel.test.mjs +146 -0
- package/packages/pi-bg-bash/test/process-control.test.mjs +300 -0
- package/packages/pi-bg-bash/test/windows-console.test.mjs +221 -0
- package/packages/pi-claude-usage/CHANGELOG.md +18 -0
- package/packages/pi-claude-usage/package.json +2 -1
- package/packages/pi-clipboard-image/CHANGELOG.md +18 -0
- package/packages/pi-clipboard-image/package.json +2 -1
- package/packages/pi-codeblock-borders/CHANGELOG.md +18 -0
- package/packages/pi-codeblock-borders/package.json +2 -1
- package/packages/pi-codegraph/CHANGELOG.md +25 -0
- package/packages/pi-codegraph/package.json +2 -1
- package/packages/pi-compact-tools/CHANGELOG.md +24 -0
- package/packages/pi-compact-tools/package.json +2 -1
- package/packages/pi-compact-tools/src/index.ts +18 -8
- package/packages/pi-compact-tools/test/behavior.test.mjs +131 -0
- package/packages/pi-decisions/CHANGELOG.md +18 -0
- package/packages/pi-decisions/package.json +2 -1
- package/packages/pi-load-pi-dotenv/CHANGELOG.md +18 -0
- package/packages/pi-load-pi-dotenv/package.json +2 -1
- package/packages/pi-model-go/CHANGELOG.md +18 -0
- package/packages/pi-model-go/package.json +2 -1
- package/packages/pi-prestart-working/CHANGELOG.md +18 -0
- package/packages/pi-prestart-working/package.json +2 -1
- package/packages/pi-root-guard/CHANGELOG.md +18 -0
- package/packages/pi-root-guard/package.json +2 -1
- package/packages/pi-session-finder/CHANGELOG.md +18 -0
- package/packages/pi-session-finder/package.json +2 -1
- package/packages/pi-session-id/CHANGELOG.md +18 -0
- package/packages/pi-session-id/package.json +2 -1
- package/packages/pi-session-nav/CHANGELOG.md +18 -0
- package/packages/pi-session-nav/package.json +2 -1
- package/packages/pi-session-rename/CHANGELOG.md +18 -0
- package/packages/pi-session-rename/package.json +2 -1
- package/packages/pi-session-retention/CHANGELOG.md +25 -0
- package/packages/pi-session-retention/package.json +2 -1
- package/packages/pi-skill-usage-log/CHANGELOG.md +18 -0
- package/packages/pi-skill-usage-log/package.json +2 -1
- package/packages/pi-tool-usage-log/CHANGELOG.md +18 -0
- package/packages/pi-tool-usage-log/package.json +2 -1
- package/packages/pi-tts/CHANGELOG.md +18 -0
- package/packages/pi-tts/package.json +2 -1
- package/packages/pi-user-image-previews/CHANGELOG.md +18 -0
- package/packages/pi-user-image-previews/package.json +2 -1
- package/packages/pi-vision/CHANGELOG.md +18 -0
- package/packages/pi-vision/package.json +2 -1
- package/packages/pi-wait/CHANGELOG.md +18 -0
- package/packages/pi-wait/package.json +2 -1
- package/packages/pi-zvec/CHANGELOG.md +25 -0
- package/packages/pi-zvec/package.json +2 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
|
|
4
|
+
/** Logs are untrusted terminal input; keep text, never terminal control sequences. */
|
|
5
|
+
export function cleanOutput(text: string): string {
|
|
6
|
+
return stripVTControlCharacters(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "").replace(/\t/g, " ");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function oneLine(text: string): string {
|
|
10
|
+
return cleanOutput(text).replace(/\s+/g, " ").trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read only a bounded suffix, including for a single enormous log line. */
|
|
14
|
+
export function readTail(path: string, maxLines = 2000, maxBytes = 48 * 1024): string {
|
|
15
|
+
let fd: number | undefined;
|
|
16
|
+
try {
|
|
17
|
+
fd = openSync(path, "r");
|
|
18
|
+
const size = fstatSync(fd).size;
|
|
19
|
+
const start = Math.max(0, size - maxBytes);
|
|
20
|
+
const buffer = Buffer.alloc(Math.min(size, maxBytes));
|
|
21
|
+
const count = readSync(fd, buffer, 0, buffer.length, start);
|
|
22
|
+
const text = cleanOutput(buffer.subarray(0, count).toString("utf8"));
|
|
23
|
+
const lines = text.split("\n");
|
|
24
|
+
const cut = start > 0 || lines.length > maxLines;
|
|
25
|
+
return (cut ? "(earlier output omitted)\n" : "") + lines.slice(-maxLines).join("\n").trimEnd();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? "(no output yet)" : "(output unavailable)";
|
|
28
|
+
} finally {
|
|
29
|
+
if (fd !== undefined) closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { oneLine } from "./output";
|
|
4
|
+
import type { StopMode } from "./process-control";
|
|
5
|
+
|
|
6
|
+
export interface JobView {
|
|
7
|
+
id: number;
|
|
8
|
+
command: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
state: string;
|
|
11
|
+
secs: number;
|
|
12
|
+
running: boolean;
|
|
13
|
+
stoppable: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Controls {
|
|
17
|
+
list(): JobView[];
|
|
18
|
+
output(id: number): string;
|
|
19
|
+
stop(id: number, mode: StopMode): Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Pick = { id: number; action: "view" | StopMode } | undefined;
|
|
23
|
+
const label = (job: JobView) => `#${job.id} [${job.tool}] ${job.state} · ${job.secs}s · ${oneLine(job.command).slice(0, 200)}`;
|
|
24
|
+
|
|
25
|
+
/** Human-only controls: inspecting output never claims or consumes agent results. */
|
|
26
|
+
export async function showJobPanel(ctx: ExtensionCommandContext, controls: Controls, signal: AbortSignal): Promise<void> {
|
|
27
|
+
if (!controls.list().length) return ctx.ui.notify("No background jobs.", "info");
|
|
28
|
+
if (!ctx.hasUI) return ctx.ui.notify(controls.list().map(label).join("\n"), "info");
|
|
29
|
+
let selected: number | undefined;
|
|
30
|
+
while (!signal.aborted) {
|
|
31
|
+
let pick: Pick;
|
|
32
|
+
if (ctx.mode === "tui") {
|
|
33
|
+
pick = await ctx.ui.custom<Pick>((tui, theme, _keys, done) => {
|
|
34
|
+
let list: SelectList;
|
|
35
|
+
let visible: JobView[] = [];
|
|
36
|
+
let disposed = false;
|
|
37
|
+
const current = () => visible.find(job => String(job.id) === list?.getSelectedItem()?.value);
|
|
38
|
+
const rebuild = () => {
|
|
39
|
+
const keep = current()?.id ?? selected;
|
|
40
|
+
visible = controls.list();
|
|
41
|
+
list = new SelectList(visible.map(job => ({ value: String(job.id), label: label(job) })),
|
|
42
|
+
Math.max(1, Math.min(10, tui.terminal.rows - 6)), {
|
|
43
|
+
selectedPrefix: text => theme.fg("accent", text), selectedText: text => theme.fg("accent", text),
|
|
44
|
+
description: text => theme.fg("muted", text), scrollInfo: text => theme.fg("dim", text),
|
|
45
|
+
noMatch: text => theme.fg("dim", text),
|
|
46
|
+
});
|
|
47
|
+
list.setSelectedIndex(Math.max(0, visible.findIndex(job => job.id === keep)));
|
|
48
|
+
list.onSelect = () => { const job = current(); if (job) finish({ id: job.id, action: "view" }); };
|
|
49
|
+
list.onCancel = () => finish(undefined);
|
|
50
|
+
};
|
|
51
|
+
const dispose = () => {
|
|
52
|
+
if (disposed) return;
|
|
53
|
+
disposed = true;
|
|
54
|
+
clearInterval(timer);
|
|
55
|
+
signal.removeEventListener("abort", cancel);
|
|
56
|
+
};
|
|
57
|
+
const finish = (value: Pick) => { dispose(); done(value); };
|
|
58
|
+
const cancel = () => finish(undefined);
|
|
59
|
+
const timer = setInterval(() => { rebuild(); tui.requestRender(); }, 1000);
|
|
60
|
+
rebuild();
|
|
61
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
62
|
+
if (signal.aborted) queueMicrotask(cancel);
|
|
63
|
+
return {
|
|
64
|
+
dispose,
|
|
65
|
+
invalidate() { list.invalidate(); },
|
|
66
|
+
render(width: number) {
|
|
67
|
+
return [theme.fg("accent", "Background jobs"), ...list.render(width),
|
|
68
|
+
theme.fg("dim", "↑↓ select · Enter output · s stop gracefully · x force kill · Esc close")].map(line => truncateToWidth(line, width));
|
|
69
|
+
},
|
|
70
|
+
handleInput(data: string) {
|
|
71
|
+
if (matchesKey(data, "x") || matchesKey(data, "s")) {
|
|
72
|
+
const job = current();
|
|
73
|
+
if (job) finish({ id: job.id, action: matchesKey(data, "x") ? "force" : "graceful" });
|
|
74
|
+
} else if (matchesKey(data, "q")) finish(undefined);
|
|
75
|
+
else list.handleInput(data);
|
|
76
|
+
tui.requestRender();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
const visible = controls.list();
|
|
82
|
+
const choices = visible.map(label);
|
|
83
|
+
const choice = await ctx.ui.select("Background jobs", choices, { signal });
|
|
84
|
+
const job = visible[choices.indexOf(choice ?? "")];
|
|
85
|
+
if (!job || signal.aborted) return;
|
|
86
|
+
const action = await ctx.ui.select(label(job), ["View output", "Stop gracefully", "Force kill", "Back"], { signal });
|
|
87
|
+
if (action === undefined) return;
|
|
88
|
+
if (action === "Back") continue;
|
|
89
|
+
pick = { id: job.id, action: action === "Stop gracefully" ? "graceful" : action === "Force kill" ? "force" : "view" };
|
|
90
|
+
}
|
|
91
|
+
if (!pick || signal.aborted) return;
|
|
92
|
+
selected = pick.id;
|
|
93
|
+
const job = controls.list().find(job => job.id === pick.id);
|
|
94
|
+
if (!job) continue;
|
|
95
|
+
if (pick.action !== "view") {
|
|
96
|
+
if (!job.stoppable) { ctx.ui.notify(`Job #${job.id} already finished.`, "info"); continue; }
|
|
97
|
+
const force = pick.action === "force";
|
|
98
|
+
if (!(await ctx.ui.confirm(`${force ? "Force kill" : "Gracefully stop"} background job #${job.id}?`,
|
|
99
|
+
`${oneLine(job.command).slice(0, 500)}\n` + (force ?
|
|
100
|
+
"Immediately terminates its tracked process tree. Cleanup hooks will not run." :
|
|
101
|
+
"Requests Ctrl+C shutdown. Survivors remain available for Force kill; no automatic escalation."), { signal })) || signal.aborted) continue;
|
|
102
|
+
ctx.ui.notify(`${force ? "Force killing" : "Gracefully stopping"} background job #${job.id}…`, "info");
|
|
103
|
+
try {
|
|
104
|
+
const message = await controls.stop(job.id, pick.action);
|
|
105
|
+
if (!signal.aborted) ctx.ui.notify(message, "info");
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!signal.aborted) ctx.ui.notify(`Could not stop job #${job.id}: ${oneLine(String(error))}`, "error");
|
|
108
|
+
}
|
|
109
|
+
} else if (ctx.mode !== "tui") ctx.ui.notify(controls.output(job.id), "info");
|
|
110
|
+
else await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
111
|
+
let text = controls.output(job.id);
|
|
112
|
+
let top = Number.POSITIVE_INFINITY;
|
|
113
|
+
let rows: string[] = [];
|
|
114
|
+
let height = 1;
|
|
115
|
+
let disposed = false;
|
|
116
|
+
const dispose = () => {
|
|
117
|
+
if (disposed) return;
|
|
118
|
+
disposed = true;
|
|
119
|
+
clearInterval(timer);
|
|
120
|
+
signal.removeEventListener("abort", finish);
|
|
121
|
+
};
|
|
122
|
+
const finish = () => { dispose(); done(); };
|
|
123
|
+
const timer = setInterval(() => {
|
|
124
|
+
const following = top >= Math.max(0, rows.length - height);
|
|
125
|
+
text = controls.output(job.id);
|
|
126
|
+
if (following) top = Number.POSITIVE_INFINITY;
|
|
127
|
+
tui.requestRender();
|
|
128
|
+
}, 1000);
|
|
129
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
130
|
+
if (signal.aborted) queueMicrotask(finish);
|
|
131
|
+
return {
|
|
132
|
+
dispose,
|
|
133
|
+
invalidate() {},
|
|
134
|
+
render(width: number) {
|
|
135
|
+
height = Math.max(1, tui.terminal.rows - 6);
|
|
136
|
+
rows = new Text(text || "(no output yet)", 0, 0).render(Math.max(1, width));
|
|
137
|
+
top = Math.max(0, Math.min(top, rows.length - height));
|
|
138
|
+
return [theme.fg("accent", label(controls.list().find(j => j.id === job.id) ?? job)),
|
|
139
|
+
...rows.slice(top, top + height),
|
|
140
|
+
theme.fg("dim", "↑↓ / PgUp / PgDn scroll · Home / End · Esc back")].map(line => truncateToWidth(line, width));
|
|
141
|
+
},
|
|
142
|
+
handleInput(data: string) {
|
|
143
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "enter")) finish();
|
|
144
|
+
else if (matchesKey(data, "up")) top = Math.max(0, top - 1);
|
|
145
|
+
else if (matchesKey(data, "down")) top = Math.min(Math.max(0, rows.length - height), top + 1);
|
|
146
|
+
else if (matchesKey(data, "pageUp")) top = Math.max(0, top - height);
|
|
147
|
+
else if (matchesKey(data, "pageDown")) top = Math.min(Math.max(0, rows.length - height), top + height);
|
|
148
|
+
else if (matchesKey(data, "home")) top = 0;
|
|
149
|
+
else if (matchesKey(data, "end")) top = Number.POSITIVE_INFINITY;
|
|
150
|
+
tui.requestRender();
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { windowsConsoleInterruptScript } from "./windows-console";
|
|
4
|
+
|
|
5
|
+
export type StopMode = "graceful" | "force";
|
|
6
|
+
|
|
7
|
+
export interface ProcessIdentity {
|
|
8
|
+
pid: number;
|
|
9
|
+
/** OS birth identifier, not a capture timestamp. Windows callers must supply a native PID. */
|
|
10
|
+
started: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ProcessRow extends ProcessIdentity {
|
|
14
|
+
ppid: number;
|
|
15
|
+
dead?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const WINDOWS = process.platform === "win32";
|
|
19
|
+
const GRACE_MS = 3_000;
|
|
20
|
+
// Retain authenticated descendants after a partial failure so retry cannot forget
|
|
21
|
+
// survivors just because their launcher exited during the previous stop.
|
|
22
|
+
const pendingTrees = new WeakMap<ProcessIdentity, Map<number, ProcessRow>>();
|
|
23
|
+
|
|
24
|
+
function validatePid(pid: number): void {
|
|
25
|
+
if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) {
|
|
26
|
+
throw new Error(`Unsafe process PID: ${pid}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateIdentity(identity: ProcessIdentity): void {
|
|
31
|
+
validatePid(identity.pid);
|
|
32
|
+
if (typeof identity.started !== "string" || !identity.started || !Number.isFinite(Date.parse(identity.started))) {
|
|
33
|
+
throw new Error("Missing or invalid process birth identity");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function run(file: string, args: string[], signal?: AbortSignal): Promise<string> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
execFile(file, args, {
|
|
40
|
+
encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 16 * 1024 * 1024, signal,
|
|
41
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
42
|
+
}, (error, stdout, stderr) => {
|
|
43
|
+
if (error) reject(error);
|
|
44
|
+
else if (stderr.trim()) reject(new Error(`Process command reported: ${stderr.trim()}`));
|
|
45
|
+
else resolve(stdout);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function powershell(script: string, signal?: AbortSignal): Promise<string> {
|
|
51
|
+
return run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
|
|
52
|
+
`$ErrorActionPreference='Stop'; ${script}`], signal);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function processTable(signal?: AbortSignal): Promise<Map<number, ProcessRow>> {
|
|
56
|
+
let rows: ProcessRow[];
|
|
57
|
+
if (WINDOWS) {
|
|
58
|
+
const output = await powershell(`$rows = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate -ErrorAction Stop | ForEach-Object {
|
|
59
|
+
@{pid=[int]$_.ProcessId; ppid=[int]$_.ParentProcessId;
|
|
60
|
+
started=$(if ($null -ne $_.CreationDate) {$_.CreationDate.ToUniversalTime().ToString('o')} else {''})}
|
|
61
|
+
}); ConvertTo-Json -Compress -InputObject $rows`, signal);
|
|
62
|
+
const parsed: unknown = JSON.parse(output);
|
|
63
|
+
if (!Array.isArray(parsed)) throw new Error("Invalid CIM process table");
|
|
64
|
+
rows = parsed;
|
|
65
|
+
} else {
|
|
66
|
+
const output = await run("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "lstart=", "-o", "stat="], signal);
|
|
67
|
+
rows = output.split(/\r?\n/).filter(line => line.trim()).map(line => {
|
|
68
|
+
const match = /^\s*(\d+)\s+(\d+)\s+([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)\s*$/.exec(line);
|
|
69
|
+
if (!match) throw new Error("Invalid ps process table row");
|
|
70
|
+
return { pid: Number(match[1]), ppid: Number(match[2]), started: match[3].replace(/\s+/g, " ") + " UTC",
|
|
71
|
+
dead: /^[ZX]/.test(match[4]) };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const table = new Map<number, ProcessRow>();
|
|
75
|
+
for (const row of rows) {
|
|
76
|
+
if (!row || !Number.isSafeInteger(row.pid) || row.pid < 0 || !Number.isSafeInteger(row.ppid) || row.ppid < 0 ||
|
|
77
|
+
typeof row.started !== "string" || (row.started && !Number.isFinite(Date.parse(row.started))) || table.has(row.pid)) {
|
|
78
|
+
throw new Error("Invalid process table");
|
|
79
|
+
}
|
|
80
|
+
table.set(row.pid, row);
|
|
81
|
+
}
|
|
82
|
+
// A successful but empty/filtered listing cannot establish absence safely.
|
|
83
|
+
if (!table.has(process.pid)) throw new Error("Incomplete process table (self missing)");
|
|
84
|
+
return table;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matching(table: Map<number, ProcessRow>, identity: ProcessIdentity): ProcessRow | undefined {
|
|
88
|
+
const row = table.get(identity.pid);
|
|
89
|
+
return row?.started === identity.started && !row.dead ? row : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Snapshot descendants only through verified, live ancestors. Never follow a reused parent PID. */
|
|
93
|
+
function descendants(table: Map<number, ProcessRow>, roots: ProcessIdentity[]): ProcessRow[] {
|
|
94
|
+
const found = new Map<number, ProcessRow>();
|
|
95
|
+
const queue = roots.map(root => matching(table, root)).filter((row): row is ProcessRow => !!row);
|
|
96
|
+
for (const root of queue) found.set(root.pid, root);
|
|
97
|
+
for (let i = 0; i < queue.length; i++) {
|
|
98
|
+
const parent = queue[i];
|
|
99
|
+
for (const child of table.values()) {
|
|
100
|
+
if (child.ppid !== parent.pid || child.pid === parent.pid || child.dead) continue;
|
|
101
|
+
validateIdentity(child);
|
|
102
|
+
// Windows retains the numeric parent PID after exit; it may now belong to a newer process.
|
|
103
|
+
if (Date.parse(child.started) < Date.parse(parent.started)) continue;
|
|
104
|
+
if (!found.has(child.pid)) {
|
|
105
|
+
found.set(child.pid, child);
|
|
106
|
+
queue.push(child);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return [...found.values()];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** An exited/reused parent cannot authenticate newly observed children. Do not adopt them. */
|
|
114
|
+
function uncertainChildren(table: Map<number, ProcessRow>, targets: Map<number, ProcessRow>): boolean {
|
|
115
|
+
return [...table.values()].some(row => {
|
|
116
|
+
const parent = targets.get(row.ppid);
|
|
117
|
+
return parent && !row.dead && !matching(table, parent) &&
|
|
118
|
+
targets.get(row.pid)?.started !== row.started &&
|
|
119
|
+
(!row.started || Date.parse(row.started) >= Date.parse(parent.started));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Snapshot ownership evidence only; this cheap query does not establish liveness. */
|
|
124
|
+
export function hasTrackedDescendants(identity: ProcessIdentity): boolean {
|
|
125
|
+
return (pendingTrees.get(identity)?.size ?? 0) > 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Capture a real OS PID and known descendants. Does not translate MSYS PIDs. */
|
|
129
|
+
export async function captureProcess(pid: number, signal?: AbortSignal): Promise<ProcessIdentity> {
|
|
130
|
+
validatePid(pid);
|
|
131
|
+
const table = await processTable(signal);
|
|
132
|
+
const row = table.get(pid);
|
|
133
|
+
if (!row || row.dead) throw new Error(`Process ${pid} is gone`);
|
|
134
|
+
validateIdentity(row);
|
|
135
|
+
const identity = { pid: row.pid, started: row.started };
|
|
136
|
+
// Remember already-owned descendants at launch, not only at the first stop.
|
|
137
|
+
// A launcher exiting later must not make these children undiscoverable.
|
|
138
|
+
pendingTrees.set(identity, new Map(descendants(table, [identity]).map(child => [child.pid, child])));
|
|
139
|
+
return identity;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function windowsKillScript(identity: ProcessIdentity): string {
|
|
143
|
+
// CIM records microseconds; .NET also exposes a seventh, 100ns digit.
|
|
144
|
+
const birth = identity.started.replace(/(\.\d{6})\dZ$/, "$1Z").replace(/'/g, "''");
|
|
145
|
+
return `$p = $null;
|
|
146
|
+
try { $p = [System.Diagnostics.Process]::GetProcessById(${identity.pid}) }
|
|
147
|
+
catch {
|
|
148
|
+
if ($_.Exception -isnot [System.ArgumentException] -and
|
|
149
|
+
$_.Exception.InnerException -isnot [System.ArgumentException]) { throw }
|
|
150
|
+
}
|
|
151
|
+
if ($null -ne $p) {
|
|
152
|
+
try {
|
|
153
|
+
# Cache the OS handle before checking birth; act on that same process object.
|
|
154
|
+
$null = $p.Handle;
|
|
155
|
+
if (($p.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.ffffff') + 'Z') -ceq '${birth}') {
|
|
156
|
+
$p.Kill()
|
|
157
|
+
}
|
|
158
|
+
} finally { $p.Dispose() }
|
|
159
|
+
}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function signalAll(targets: ProcessIdentity[], mode: StopMode): Promise<void> {
|
|
163
|
+
for (const identity of targets) validateIdentity(identity);
|
|
164
|
+
if (WINDOWS) {
|
|
165
|
+
// One interpreter, not one per descendant. Every kill validates the birth
|
|
166
|
+
// identity against an open OS handle; console interruption is separate.
|
|
167
|
+
if (targets.length) await powershell(targets.map(windowsKillScript).join("\n"));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for (const identity of targets) {
|
|
171
|
+
// ps + kill is not atomic; keep the check immediately adjacent to each signal.
|
|
172
|
+
if (!matching(await processTable(), identity)) continue;
|
|
173
|
+
try { process.kill(identity.pid, mode === "force" ? "SIGKILL" : "SIGINT"); }
|
|
174
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Stop the snapshotted tree without automatic escalation. Graceful means SIGINT
|
|
180
|
+
* on Unix or a safely isolated console Ctrl+C on Windows; force skips grace.
|
|
181
|
+
* 'gone' means the root was already absent/reused before
|
|
182
|
+
* any action; 'stopped' means all tracked identities are gone; 'stuck' means a
|
|
183
|
+
* survivor or newly observed descendant remains. Enumeration/permission errors
|
|
184
|
+
* reject, never masquerade as absence. No names, ports, groups, or PID 0 used.
|
|
185
|
+
*
|
|
186
|
+
* This is best-effort process control, not containment: children that fork and
|
|
187
|
+
* reparent between snapshots cannot be recovered safely. Unix ps birth times
|
|
188
|
+
* have one-second precision, and portable PID signaling has an unavoidable
|
|
189
|
+
* check/signal race. Stronger guarantees require OS job objects/pidfds.
|
|
190
|
+
*/
|
|
191
|
+
export async function stopProcessTree(identity: ProcessIdentity, mode: StopMode = "force"): Promise<"stopped" | "gone" | "stuck"> {
|
|
192
|
+
validateIdentity(identity);
|
|
193
|
+
if (mode !== "graceful" && mode !== "force") throw new Error("Invalid process stop mode");
|
|
194
|
+
let table = await processTable();
|
|
195
|
+
const previous = pendingTrees.get(identity);
|
|
196
|
+
if (!matching(table, identity) && !previous) return "gone";
|
|
197
|
+
const targets = previous ?? new Map<number, ProcessRow>();
|
|
198
|
+
for (const row of descendants(table, [identity, ...targets.values()])) targets.set(row.pid, row);
|
|
199
|
+
// Validate the entire snapshot before taking any destructive action.
|
|
200
|
+
for (const target of targets.values()) validateIdentity(target);
|
|
201
|
+
pendingTrees.set(identity, targets);
|
|
202
|
+
const live = [...targets.values()].filter(target => matching(table, target));
|
|
203
|
+
if (!live.length && !uncertainChildren(table, targets)) {
|
|
204
|
+
pendingTrees.delete(identity);
|
|
205
|
+
return "gone";
|
|
206
|
+
}
|
|
207
|
+
if (WINDOWS && mode === "graceful") {
|
|
208
|
+
// A console event reaches its console, not a PID. The helper authenticates
|
|
209
|
+
// every attached process and rejects shared/unknown consoles before sending.
|
|
210
|
+
if (live.length) await powershell(windowsConsoleInterruptScript(live[0], live, process.pid));
|
|
211
|
+
} else await signalAll(live.reverse(), mode);
|
|
212
|
+
// Graceful stopping never kills survivors. Force only waits for OS retirement.
|
|
213
|
+
await new Promise(resolve => setTimeout(resolve, mode === "graceful" ? GRACE_MS : 100));
|
|
214
|
+
table = await processTable();
|
|
215
|
+
const uncertain = uncertainChildren(table, targets);
|
|
216
|
+
const survivors = descendants(table, [...targets.values()]);
|
|
217
|
+
for (const child of survivors) targets.set(child.pid, child);
|
|
218
|
+
if (uncertain || survivors.length) return "stuck";
|
|
219
|
+
pendingTrees.delete(identity);
|
|
220
|
+
return "stopped";
|
|
221
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ProcessIdentity } from "./process-control";
|
|
2
|
+
|
|
3
|
+
/** Run only inside the new job console, before launching its actual command. */
|
|
4
|
+
export const windowsConsoleInputPrelude = `
|
|
5
|
+
$ErrorActionPreference = 'Stop'
|
|
6
|
+
Add-Type -TypeDefinition @'
|
|
7
|
+
using System;
|
|
8
|
+
using System.Runtime.InteropServices;
|
|
9
|
+
public static class PiBgConsoleInput {
|
|
10
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
11
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetConsoleCtrlHandler(IntPtr handler, bool add);
|
|
12
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
13
|
+
public static extern IntPtr CreateFile(string name, uint access, uint share, IntPtr attrs, uint disposition, uint flags, IntPtr template);
|
|
14
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
15
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags);
|
|
16
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
17
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetStdHandle(int id, IntPtr handle);
|
|
18
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
19
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool CloseHandle(IntPtr handle);
|
|
20
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
21
|
+
public static extern uint GetConsoleProcessList([Out] uint[] list, uint size);
|
|
22
|
+
}
|
|
23
|
+
'@
|
|
24
|
+
# Detached launches can inherit an ignore-Ctrl+C attribute. Clear it in the job
|
|
25
|
+
# console BEFORE children start; registering a Node SIGINT listener alone cannot.
|
|
26
|
+
if (-not [PiBgConsoleInput]::SetConsoleCtrlHandler([IntPtr]::Zero, $false)) {
|
|
27
|
+
throw ('Cannot enable console Ctrl+C (Win32 ' + [Runtime.InteropServices.Marshal]::GetLastWin32Error() + ')')
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/** Bash needs inheritable CONIN$, not PowerShell's native-command stdin pipe. */
|
|
32
|
+
export function windowsBashLaunchScript(commandPath: string): string {
|
|
33
|
+
return windowsConsoleInputPrelude + `
|
|
34
|
+
$clients = New-Object System.UInt32[] 2
|
|
35
|
+
$count = [PiBgConsoleInput]::GetConsoleProcessList($clients, $clients.Length)
|
|
36
|
+
if ($count -ne 1 -or $clients[0] -ne $PID) { throw 'Job console is not isolated; refusing to attach input' }
|
|
37
|
+
$inputHandle = [PiBgConsoleInput]::CreateFile('CONIN$', [uint32]3221225472, 3, [IntPtr]::Zero, 3, 0, [IntPtr]::Zero)
|
|
38
|
+
if ($inputHandle -eq [IntPtr](-1)) { throw 'Cannot open job console input' }
|
|
39
|
+
try {
|
|
40
|
+
if (-not [PiBgConsoleInput]::SetHandleInformation($inputHandle, 1, 1)) { throw 'Cannot inherit job console input' }
|
|
41
|
+
if (-not [PiBgConsoleInput]::SetStdHandle(-10, $inputHandle)) { throw 'Cannot set job console input' }
|
|
42
|
+
$start = New-Object System.Diagnostics.ProcessStartInfo
|
|
43
|
+
$start.FileName = $env:PI_BG_BASH_EXECUTABLE
|
|
44
|
+
$start.Arguments = '-- "${commandPath.replace(/'/g, "''")}"'
|
|
45
|
+
$start.UseShellExecute = $false
|
|
46
|
+
# Avoid PowerShell's native-command pipeline, which replaces stdin with a
|
|
47
|
+
# pipe and makes MSYS suppress native children's Ctrl+C handlers again.
|
|
48
|
+
$child = [System.Diagnostics.Process]::Start($start)
|
|
49
|
+
try { $child.WaitForExit(); $code = $child.ExitCode }
|
|
50
|
+
finally { $child.Dispose() }
|
|
51
|
+
exit $code
|
|
52
|
+
} finally { [void][PiBgConsoleInput]::CloseHandle($inputHandle) }
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run in a disposable powershell.exe, never in Pi's own console host.
|
|
58
|
+
* Console enumeration/broadcast is not atomic: this is not hostile-process containment.
|
|
59
|
+
*/
|
|
60
|
+
export function windowsConsoleInterruptScript(root: ProcessIdentity, targets: ProcessIdentity[], ownerPid: number): string {
|
|
61
|
+
// Callers validate the tree; also keep this serialization boundary non-executable.
|
|
62
|
+
const validPid = (pid: number) => Number.isSafeInteger(pid) && pid > 1 && pid <= 0xffffffff;
|
|
63
|
+
if (!validPid(ownerPid)) throw new Error("Unsafe console owner PID");
|
|
64
|
+
const known = new Map<number, string>();
|
|
65
|
+
for (const identity of [root, ...targets]) {
|
|
66
|
+
if (!validPid(identity.pid) || identity.pid === ownerPid ||
|
|
67
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}0Z$/.test(identity.started) ||
|
|
68
|
+
!Number.isFinite(Date.parse(identity.started))) {
|
|
69
|
+
throw new Error("Invalid Windows console process identity");
|
|
70
|
+
}
|
|
71
|
+
const birth = identity.started.replace(/0Z$/, "Z");
|
|
72
|
+
if (known.has(identity.pid) && known.get(identity.pid) !== birth) throw new Error("Conflicting console process identities");
|
|
73
|
+
known.set(identity.pid, birth);
|
|
74
|
+
}
|
|
75
|
+
return `$ErrorActionPreference = 'Stop'
|
|
76
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
77
|
+
Add-Type -TypeDefinition @'
|
|
78
|
+
using System;
|
|
79
|
+
using System.Runtime.InteropServices;
|
|
80
|
+
public static class PiBgConsole {
|
|
81
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
82
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool FreeConsole();
|
|
83
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
84
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool AttachConsole(uint pid);
|
|
85
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
86
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool SetConsoleCtrlHandler(IntPtr handler, bool add);
|
|
87
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
88
|
+
public static extern uint GetConsoleProcessList([Out] uint[] list, uint size);
|
|
89
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
90
|
+
[return: MarshalAs(UnmanagedType.Bool)] public static extern bool GenerateConsoleCtrlEvent(uint controlEvent, uint group);
|
|
91
|
+
}
|
|
92
|
+
'@
|
|
93
|
+
$known = @{ ${[...known].map(([pid, birth]) => `${pid} = '${birth}'`).join("; ")} }
|
|
94
|
+
$handles = @{}
|
|
95
|
+
$attached = $false
|
|
96
|
+
function Console-Error([string] $operation) {
|
|
97
|
+
throw ($operation + ' failed (Win32 ' + [Runtime.InteropServices.Marshal]::GetLastWin32Error() + ')')
|
|
98
|
+
}
|
|
99
|
+
function Get-ConsoleMembers {
|
|
100
|
+
$capacity = 16
|
|
101
|
+
for ($attempt = 0; $attempt -lt 4; $attempt++) {
|
|
102
|
+
$members = New-Object System.UInt32[] $capacity
|
|
103
|
+
$count = [PiBgConsole]::GetConsoleProcessList($members, $members.Length)
|
|
104
|
+
if ($count -eq 0) { Console-Error 'GetConsoleProcessList' }
|
|
105
|
+
if ($count -gt 65536) { throw 'Console membership exceeds safety bound' }
|
|
106
|
+
if ($count -le $members.Length) {
|
|
107
|
+
$result = @($members[0..([int]$count - 1)])
|
|
108
|
+
if (@($result | Select-Object -Unique).Count -ne $result.Count) { throw 'Invalid console membership' }
|
|
109
|
+
return $result
|
|
110
|
+
}
|
|
111
|
+
$capacity = [int]$count
|
|
112
|
+
}
|
|
113
|
+
throw 'Console membership did not stabilize'
|
|
114
|
+
}
|
|
115
|
+
function Assert-Identity([int] $targetPid) {
|
|
116
|
+
if (-not $known.ContainsKey($targetPid)) { throw ('Unauthenticated console process ' + $targetPid) }
|
|
117
|
+
if (-not $handles.ContainsKey($targetPid)) {
|
|
118
|
+
$p = [System.Diagnostics.Process]::GetProcessById($targetPid)
|
|
119
|
+
# Store before accessing Handle so finally also disposes partial failures.
|
|
120
|
+
$handles[$targetPid] = $p
|
|
121
|
+
$null = $p.Handle
|
|
122
|
+
}
|
|
123
|
+
$p = $handles[$targetPid]
|
|
124
|
+
$p.Refresh()
|
|
125
|
+
if ($p.HasExited -or ($p.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.ffffff', [Globalization.CultureInfo]::InvariantCulture) + 'Z') -cne $known[$targetPid]) {
|
|
126
|
+
throw ('Process birth identity changed or process exited: ' + $targetPid)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function Assert-Members($members) {
|
|
130
|
+
if ($members -contains ${ownerPid}) { throw 'Console contains Pi owner process' }
|
|
131
|
+
if ($members -notcontains ${root.pid}) { throw 'Root absent from console' }
|
|
132
|
+
if ($members -notcontains $PID) { throw 'Helper absent from attached console' }
|
|
133
|
+
foreach ($member in $members) {
|
|
134
|
+
if ($member -ne $PID) { Assert-Identity ([int]$member) }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
if ($known.ContainsKey([int]$PID)) { throw 'Helper PID conflicts with target' }
|
|
139
|
+
Assert-Identity ${root.pid}
|
|
140
|
+
foreach ($targetPid in @($known.Keys)) { Assert-Identity $targetPid }
|
|
141
|
+
if (-not [PiBgConsole]::FreeConsole()) {
|
|
142
|
+
# A pipe-only helper may start without any console.
|
|
143
|
+
if ([Runtime.InteropServices.Marshal]::GetLastWin32Error() -ne 6) { Console-Error 'FreeConsole' }
|
|
144
|
+
}
|
|
145
|
+
if (-not [PiBgConsole]::AttachConsole(${root.pid})) { Console-Error 'AttachConsole (root has no usable console)' }
|
|
146
|
+
$attached = $true
|
|
147
|
+
# AttachConsole resets the handler table: ignore Ctrl+C only AFTER attachment.
|
|
148
|
+
if (-not [PiBgConsole]::SetConsoleCtrlHandler([IntPtr]::Zero, $true)) { Console-Error 'SetConsoleCtrlHandler' }
|
|
149
|
+
$members = @(Get-ConsoleMembers)
|
|
150
|
+
Assert-Members $members
|
|
151
|
+
$again = @(Get-ConsoleMembers)
|
|
152
|
+
Assert-Members $again
|
|
153
|
+
if ((($members | Sort-Object) -join ',') -cne (($again | Sort-Object) -join ',')) { throw 'Console membership changed during validation' }
|
|
154
|
+
# Cached OS handles pin the authenticated process objects. Check births/liveness
|
|
155
|
+
# again, then take the final membership snapshot immediately before broadcast.
|
|
156
|
+
foreach ($targetPid in @($handles.Keys)) { Assert-Identity $targetPid }
|
|
157
|
+
$last = @(Get-ConsoleMembers)
|
|
158
|
+
if ((($again | Sort-Object) -join ',') -cne (($last | Sort-Object) -join ',')) { throw 'Console membership changed before Ctrl+C' }
|
|
159
|
+
if (-not [PiBgConsole]::GenerateConsoleCtrlEvent(0, 0)) { Console-Error 'GenerateConsoleCtrlEvent' }
|
|
160
|
+
# Delivery is asynchronous; do not tear down the sender immediately.
|
|
161
|
+
Start-Sleep -Milliseconds 250
|
|
162
|
+
} catch {
|
|
163
|
+
throw ('Graceful stop unsupported/error: ' + $_.Exception.Message)
|
|
164
|
+
} finally {
|
|
165
|
+
if ($attached) { $null = [PiBgConsole]::FreeConsole() }
|
|
166
|
+
foreach ($p in $handles.Values) { $p.Dispose() }
|
|
167
|
+
}
|
|
168
|
+
`;
|
|
169
|
+
}
|