@mystilleef/pi-subagent 0.9.0 → 0.10.2
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 +68 -40
- package/package.json +8 -8
- package/src/agent/agent-cache.ts +1 -0
- package/src/agent/agents.ts +22 -1
- package/src/child/child-events.ts +2 -0
- package/src/child/process.ts +196 -84
- package/src/child/termination.ts +316 -1
- package/src/env.d.ts +2 -0
- package/src/notification/delivery.ts +231 -0
- package/src/notification/desktop-notification.ts +73 -0
- package/src/orchestration/run-command.ts +1 -1
- package/src/orchestration/subagent-orchestrator.ts +49 -79
- package/src/output/normalize.ts +2 -2
- package/src/output/ui.ts +17 -28
- package/src/progress/progress-format.ts +111 -0
- package/src/progress/progress-state.ts +20 -108
- package/src/progress/progress.ts +29 -27
- package/src/progress/result-details.ts +96 -33
- package/src/shared/types.ts +2 -0
- package/src/shared/utils.ts +27 -6
package/src/child/termination.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
|
|
3
4
|
export type TerminationSignal = "SIGTERM" | "SIGKILL";
|
|
4
5
|
|
|
@@ -12,6 +13,39 @@ export type TerminationMetadata = {
|
|
|
12
13
|
fallbackCause?: string | undefined;
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
export type SleepInhibitorHandle = {
|
|
17
|
+
release: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SleepInhibitorAdapterHandle = {
|
|
21
|
+
release?: () => unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SleepInhibitorAdapter = {
|
|
25
|
+
supported?: () => boolean | Promise<boolean>;
|
|
26
|
+
acquire: (pid: number) => unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SleepInhibitorHelperProcess = {
|
|
30
|
+
exitCode?: number | null;
|
|
31
|
+
signalCode?: NodeJS.Signals | string | null;
|
|
32
|
+
kill?: (signal?: NodeJS.Signals | number) => unknown;
|
|
33
|
+
on?: (event: "error", listener: (error: Error) => void) => unknown;
|
|
34
|
+
unref?: () => unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type HostSleepInhibitorAdapterOptions = {
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
environment?: Partial<NodeJS.ProcessEnv>;
|
|
40
|
+
getEnvironment?: () => Partial<NodeJS.ProcessEnv>;
|
|
41
|
+
commandExists?: (command: string) => boolean | Promise<boolean>;
|
|
42
|
+
spawnHelper?: (
|
|
43
|
+
command: string,
|
|
44
|
+
args: string[],
|
|
45
|
+
options: { stdio: "ignore"; detached: true },
|
|
46
|
+
) => SleepInhibitorHelperProcess;
|
|
47
|
+
};
|
|
48
|
+
|
|
15
49
|
type TimerHandle = unknown;
|
|
16
50
|
|
|
17
51
|
type TerminationState = {
|
|
@@ -43,6 +77,15 @@ export type TerminateChildProcessOptions = {
|
|
|
43
77
|
};
|
|
44
78
|
|
|
45
79
|
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
80
|
+
const CAFFEINATE_COMMAND = "caffeinate";
|
|
81
|
+
const SYSTEMD_INHIBIT_COMMAND = "systemd-inhibit";
|
|
82
|
+
const GNOME_SESSION_INHIBIT_COMMAND = "gnome-session-inhibit";
|
|
83
|
+
const KDE_INHIBIT_COMMAND = "kde-inhibit";
|
|
84
|
+
const POWERSHELL_COMMAND = "powershell.exe";
|
|
85
|
+
const SHELL_COMMAND = "/bin/sh";
|
|
86
|
+
const noopSleepInhibitorHandle: SleepInhibitorHandle = {
|
|
87
|
+
async release() {},
|
|
88
|
+
};
|
|
46
89
|
const terminationStates = new WeakMap<ChildProcess, TerminationState>();
|
|
47
90
|
|
|
48
91
|
export function getProcessTreeSpawnOptions(
|
|
@@ -52,12 +95,284 @@ export function getProcessTreeSpawnOptions(
|
|
|
52
95
|
return tree && platform !== "win32" ? { detached: true } : {};
|
|
53
96
|
}
|
|
54
97
|
|
|
98
|
+
export function isFinitePid(pid: unknown): pid is number {
|
|
99
|
+
return typeof pid === "number" && Number.isFinite(pid);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isAdapterHandle(
|
|
103
|
+
handle: unknown,
|
|
104
|
+
): handle is SleepInhibitorAdapterHandle {
|
|
105
|
+
return typeof handle === "object" && handle !== null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeSleepInhibitorHandle(
|
|
109
|
+
adapterHandle: SleepInhibitorAdapterHandle,
|
|
110
|
+
): SleepInhibitorHandle {
|
|
111
|
+
let released = false;
|
|
112
|
+
return {
|
|
113
|
+
async release() {
|
|
114
|
+
if (released) return;
|
|
115
|
+
released = true;
|
|
116
|
+
try {
|
|
117
|
+
await adapterHandle.release?.();
|
|
118
|
+
} catch {
|
|
119
|
+
/* adapter release failures are non-fatal */
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function defaultCommandExists(command: string): Promise<boolean> {
|
|
126
|
+
try {
|
|
127
|
+
return await new Promise<boolean>((resolve) => {
|
|
128
|
+
const child = spawn("/bin/sh", ["-c", `command -v ${command}`], {
|
|
129
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
130
|
+
});
|
|
131
|
+
child.on("error", () => resolve(false));
|
|
132
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
133
|
+
});
|
|
134
|
+
} catch {
|
|
135
|
+
/* missing, non-executable, failed, or throwing lookups return false */
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function helperHasEnded(helper: SleepInhibitorHelperProcess): boolean {
|
|
141
|
+
return (
|
|
142
|
+
(helper.exitCode ?? null) !== null || (helper.signalCode ?? null) !== null
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function makeHelperHandle(
|
|
147
|
+
helper: SleepInhibitorHelperProcess,
|
|
148
|
+
): SleepInhibitorAdapterHandle {
|
|
149
|
+
helper.on?.("error", () => undefined);
|
|
150
|
+
helper.unref?.();
|
|
151
|
+
return {
|
|
152
|
+
release() {
|
|
153
|
+
if (helperHasEnded(helper)) return;
|
|
154
|
+
helper.kill?.("SIGTERM");
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getDesktopTokens(desktop: string | undefined): Set<string> {
|
|
160
|
+
return new Set(
|
|
161
|
+
(desktop ?? "")
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.split(/[\s:;,+/]+/u)
|
|
164
|
+
.filter(Boolean),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getLinuxDesktopTokens(
|
|
169
|
+
getEnvironment: () => Partial<NodeJS.ProcessEnv>,
|
|
170
|
+
): Set<string> {
|
|
171
|
+
try {
|
|
172
|
+
return getDesktopTokens(getEnvironment()["XDG_CURRENT_DESKTOP"]);
|
|
173
|
+
} catch {
|
|
174
|
+
return new Set<string>();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function hasGnomeCompatibleDesktop(tokens: Set<string>): boolean {
|
|
179
|
+
return tokens.has("gnome") || tokens.has("ubuntu");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function hasKdeCompatibleDesktop(tokens: Set<string>): boolean {
|
|
183
|
+
return tokens.has("kde") || tokens.has("plasma");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function commandExistsSafely(
|
|
187
|
+
commandExists: (command: string) => boolean | Promise<boolean>,
|
|
188
|
+
command: string,
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
try {
|
|
191
|
+
return await commandExists(command);
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function makePidPollingShellArgs(pid: number): string[] {
|
|
198
|
+
return [
|
|
199
|
+
SHELL_COMMAND,
|
|
200
|
+
"-c",
|
|
201
|
+
`while kill -0 ${pid} 2>/dev/null && [ "$(cut -d' ' -f4 /proc/$$/stat 2>/dev/null)" != "1" ]; do sleep 1; done`,
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function makeSystemdInhibitArgs(pid: number): string[] {
|
|
206
|
+
return [
|
|
207
|
+
"--what=sleep:idle",
|
|
208
|
+
"--who=pi-subagent",
|
|
209
|
+
"--why=subagent running",
|
|
210
|
+
"--mode=block",
|
|
211
|
+
...makePidPollingShellArgs(pid),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function makeGnomeSessionInhibitArgs(pid: number): string[] {
|
|
216
|
+
return [
|
|
217
|
+
"--app-id=pi-subagent",
|
|
218
|
+
"--inhibit=suspend:idle",
|
|
219
|
+
"--reason=subagent running",
|
|
220
|
+
...makePidPollingShellArgs(pid),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function makeKdeInhibitArgs(pid: number): string[] {
|
|
225
|
+
return ["--power", "--screenSaver", ...makePidPollingShellArgs(pid)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function makePowerShellInhibitArgs(pid: number): string[] {
|
|
229
|
+
const script = [
|
|
230
|
+
"Add-Type -Namespace PiSubagent -Name NativeMethods -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint esFlags);'",
|
|
231
|
+
"[PiSubagent.NativeMethods]::SetThreadExecutionState(0x80000001) | Out-Null",
|
|
232
|
+
`try { while (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 1 } } finally { [PiSubagent.NativeMethods]::SetThreadExecutionState(0x80000000) | Out-Null }`,
|
|
233
|
+
].join("; ");
|
|
234
|
+
return ["-NonInteractive", "-Command", script];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function acquireLinuxSleepInhibitor(
|
|
238
|
+
pid: number,
|
|
239
|
+
getEnvironment: () => Partial<NodeJS.ProcessEnv>,
|
|
240
|
+
commandExists: (command: string) => boolean | Promise<boolean>,
|
|
241
|
+
spawnHelper: (
|
|
242
|
+
command: string,
|
|
243
|
+
args: string[],
|
|
244
|
+
options: { stdio: "ignore"; detached: true },
|
|
245
|
+
) => SleepInhibitorHelperProcess,
|
|
246
|
+
): Promise<SleepInhibitorAdapterHandle> {
|
|
247
|
+
const spawnSingleHelper = (
|
|
248
|
+
command: string,
|
|
249
|
+
args: string[],
|
|
250
|
+
): SleepInhibitorAdapterHandle => {
|
|
251
|
+
try {
|
|
252
|
+
return makeHelperHandle(
|
|
253
|
+
spawnHelper(command, args, {
|
|
254
|
+
stdio: "ignore",
|
|
255
|
+
detached: true,
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
} catch {
|
|
259
|
+
/* chosen-helper spawn failures degrade to empty handle */
|
|
260
|
+
return {};
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const tokens = getLinuxDesktopTokens(getEnvironment);
|
|
264
|
+
if (
|
|
265
|
+
hasGnomeCompatibleDesktop(tokens) &&
|
|
266
|
+
(await commandExistsSafely(commandExists, GNOME_SESSION_INHIBIT_COMMAND))
|
|
267
|
+
)
|
|
268
|
+
return spawnSingleHelper(
|
|
269
|
+
GNOME_SESSION_INHIBIT_COMMAND,
|
|
270
|
+
makeGnomeSessionInhibitArgs(pid),
|
|
271
|
+
);
|
|
272
|
+
if (
|
|
273
|
+
hasKdeCompatibleDesktop(tokens) &&
|
|
274
|
+
(await commandExistsSafely(commandExists, KDE_INHIBIT_COMMAND))
|
|
275
|
+
)
|
|
276
|
+
return spawnSingleHelper(KDE_INHIBIT_COMMAND, makeKdeInhibitArgs(pid));
|
|
277
|
+
if (await commandExistsSafely(commandExists, SYSTEMD_INHIBIT_COMMAND))
|
|
278
|
+
return spawnSingleHelper(
|
|
279
|
+
SYSTEMD_INHIBIT_COMMAND,
|
|
280
|
+
makeSystemdInhibitArgs(pid),
|
|
281
|
+
);
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function makeHostSleepInhibitorAdapter(
|
|
286
|
+
options: HostSleepInhibitorAdapterOptions = {},
|
|
287
|
+
): SleepInhibitorAdapter {
|
|
288
|
+
const platform = options.platform ?? process.platform;
|
|
289
|
+
const commandExists = options.commandExists ?? defaultCommandExists;
|
|
290
|
+
const getEnvironment =
|
|
291
|
+
options.getEnvironment ?? (() => options.environment ?? process.env);
|
|
292
|
+
const spawnHelper =
|
|
293
|
+
options.spawnHelper ??
|
|
294
|
+
((command, args, spawnOptions) => spawn(command, args, spawnOptions));
|
|
295
|
+
return {
|
|
296
|
+
async supported() {
|
|
297
|
+
if (platform === "win32") return true;
|
|
298
|
+
if (platform === "darwin")
|
|
299
|
+
return commandExistsSafely(commandExists, CAFFEINATE_COMMAND);
|
|
300
|
+
if (platform === "linux") {
|
|
301
|
+
const tokens = getLinuxDesktopTokens(getEnvironment);
|
|
302
|
+
if (
|
|
303
|
+
hasGnomeCompatibleDesktop(tokens) &&
|
|
304
|
+
(await commandExistsSafely(
|
|
305
|
+
commandExists,
|
|
306
|
+
GNOME_SESSION_INHIBIT_COMMAND,
|
|
307
|
+
))
|
|
308
|
+
)
|
|
309
|
+
return true;
|
|
310
|
+
if (
|
|
311
|
+
hasKdeCompatibleDesktop(tokens) &&
|
|
312
|
+
(await commandExistsSafely(commandExists, KDE_INHIBIT_COMMAND))
|
|
313
|
+
)
|
|
314
|
+
return true;
|
|
315
|
+
return commandExistsSafely(commandExists, SYSTEMD_INHIBIT_COMMAND);
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
},
|
|
319
|
+
acquire(pid) {
|
|
320
|
+
if (platform === "darwin") {
|
|
321
|
+
const helper = spawnHelper(
|
|
322
|
+
CAFFEINATE_COMMAND,
|
|
323
|
+
["-dimsu", "-w", String(pid)],
|
|
324
|
+
{ stdio: "ignore", detached: true },
|
|
325
|
+
);
|
|
326
|
+
return makeHelperHandle(helper);
|
|
327
|
+
}
|
|
328
|
+
if (platform === "linux") {
|
|
329
|
+
return acquireLinuxSleepInhibitor(
|
|
330
|
+
pid,
|
|
331
|
+
getEnvironment,
|
|
332
|
+
commandExists,
|
|
333
|
+
spawnHelper,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (platform === "win32") {
|
|
337
|
+
const helper = spawnHelper(
|
|
338
|
+
POWERSHELL_COMMAND,
|
|
339
|
+
makePowerShellInhibitArgs(pid),
|
|
340
|
+
{
|
|
341
|
+
stdio: "ignore",
|
|
342
|
+
detached: true,
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
return makeHelperHandle(helper);
|
|
346
|
+
}
|
|
347
|
+
return {};
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function acquireChildSleepInhibitor(
|
|
353
|
+
pid: unknown,
|
|
354
|
+
adapter?: SleepInhibitorAdapter,
|
|
355
|
+
): Promise<SleepInhibitorHandle> {
|
|
356
|
+
if (!isFinitePid(pid) || !adapter) return noopSleepInhibitorHandle;
|
|
357
|
+
try {
|
|
358
|
+
if ((await adapter.supported?.()) === false)
|
|
359
|
+
return noopSleepInhibitorHandle;
|
|
360
|
+
const handle = await adapter.acquire(pid);
|
|
361
|
+
return isAdapterHandle(handle)
|
|
362
|
+
? makeSleepInhibitorHandle(handle)
|
|
363
|
+
: noopSleepInhibitorHandle;
|
|
364
|
+
} catch {
|
|
365
|
+
/* unsupported platforms or acquisition failures degrade to no-op handle */
|
|
366
|
+
return noopSleepInhibitorHandle;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
55
370
|
function childHasExited(proc: ChildProcess): boolean {
|
|
56
371
|
return proc.exitCode !== null || proc.signalCode != null;
|
|
57
372
|
}
|
|
58
373
|
|
|
59
374
|
function hasPid(proc: ChildProcess): proc is ChildProcess & { pid: number } {
|
|
60
|
-
return
|
|
375
|
+
return isFinitePid(proc.pid);
|
|
61
376
|
}
|
|
62
377
|
|
|
63
378
|
function settleState(state: TerminationState): void {
|
package/src/env.d.ts
CHANGED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform delivery implementations for desktop notifications.
|
|
3
|
+
*
|
|
4
|
+
* Delivers notification requests through safe native commands with
|
|
5
|
+
* async bounded subprocess handling and silent degradation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { access, stat } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { NotificationRequest } from "./desktop-notification.js";
|
|
12
|
+
|
|
13
|
+
const LINUX_NOTIFY_SEND = "notify-send";
|
|
14
|
+
|
|
15
|
+
export type DeliveryDependencies = {
|
|
16
|
+
platform?: NodeJS.Platform;
|
|
17
|
+
commandExists?: (command: string) => boolean | Promise<boolean>;
|
|
18
|
+
spawnProcess?: (
|
|
19
|
+
command: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
options: { stdio: "ignore" },
|
|
22
|
+
) => { unref?: () => void };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SpawnProcess = NonNullable<DeliveryDependencies["spawnProcess"]>;
|
|
26
|
+
|
|
27
|
+
type NotificationCommand = {
|
|
28
|
+
command: string;
|
|
29
|
+
args: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type BuildNotificationCommand = (
|
|
33
|
+
request: NotificationRequest,
|
|
34
|
+
) => NotificationCommand;
|
|
35
|
+
|
|
36
|
+
type NotificationDelivery = (
|
|
37
|
+
request: NotificationRequest,
|
|
38
|
+
deps?: DeliveryDependencies,
|
|
39
|
+
) => Promise<void>;
|
|
40
|
+
|
|
41
|
+
export async function defaultCommandExists(command: string): Promise<boolean> {
|
|
42
|
+
const pathEnv = process.env.PATH;
|
|
43
|
+
if (!pathEnv) return false;
|
|
44
|
+
const entries = pathEnv.split(":").filter((e) => e.length > 0);
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const dir = entry.replace(/\/+$/, "");
|
|
47
|
+
const candidate = join(dir, command);
|
|
48
|
+
try {
|
|
49
|
+
const info = await stat(candidate);
|
|
50
|
+
if (!info.isFile()) continue;
|
|
51
|
+
await access(candidate, 1); // X_OK
|
|
52
|
+
return true;
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let defaultDeliveryDeps: DeliveryDependencies | undefined;
|
|
59
|
+
|
|
60
|
+
export function setDefaultDeliveryDeps(
|
|
61
|
+
deps: DeliveryDependencies | undefined,
|
|
62
|
+
): void {
|
|
63
|
+
defaultDeliveryDeps = deps;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resetDefaultDeliveryDeps(): void {
|
|
67
|
+
defaultDeliveryDeps = undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let notifySendAvailable: boolean | undefined;
|
|
71
|
+
|
|
72
|
+
export function resetNotifySendCache(): void {
|
|
73
|
+
notifySendAvailable = undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function checkNotifySendExists(
|
|
77
|
+
commandExists: (command: string) => boolean | Promise<boolean>,
|
|
78
|
+
): Promise<boolean> {
|
|
79
|
+
if (notifySendAvailable !== undefined) return notifySendAvailable;
|
|
80
|
+
try {
|
|
81
|
+
notifySendAvailable = await commandExists(LINUX_NOTIFY_SEND);
|
|
82
|
+
} catch {
|
|
83
|
+
notifySendAvailable = false;
|
|
84
|
+
}
|
|
85
|
+
return notifySendAvailable;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function escapeLinuxArgs(request: NotificationRequest): string[] {
|
|
89
|
+
const args: string[] = [];
|
|
90
|
+
if (request.urgency === "critical") args.push("--urgency=critical");
|
|
91
|
+
args.push(`--expire-time=${request.timeoutMs}`);
|
|
92
|
+
args.push(request.title);
|
|
93
|
+
args.push(request.body);
|
|
94
|
+
return args;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function escapeAppleScriptString(value: string): string {
|
|
98
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildAppleScript(request: NotificationRequest): string {
|
|
102
|
+
const title = escapeAppleScriptString(request.title);
|
|
103
|
+
const body = escapeAppleScriptString(request.body);
|
|
104
|
+
return `display notification "${body}" with title "${title}"`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function escapePowerShellString(value: string): string {
|
|
108
|
+
return value.replace(/'/g, "''");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildPowerShellScript(request: NotificationRequest): string {
|
|
112
|
+
const title = escapePowerShellString(request.title);
|
|
113
|
+
const body = escapePowerShellString(request.body);
|
|
114
|
+
return [
|
|
115
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
116
|
+
`$notify = New-Object System.Windows.Forms.NotifyIcon`,
|
|
117
|
+
`$notify.Icon = [System.Drawing.SystemIcons]::Information`,
|
|
118
|
+
`$notify.Visible = $true`,
|
|
119
|
+
`$notify.ShowBalloonTip(${request.timeoutMs}, '${title}', '${body}', [System.Windows.Forms.ToolTipIcon]::Info)`,
|
|
120
|
+
`Start-Sleep -Milliseconds ${request.timeoutMs + 500}`,
|
|
121
|
+
`$notify.Dispose()`,
|
|
122
|
+
].join("; ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getSpawnProcess(deps: DeliveryDependencies): SpawnProcess {
|
|
126
|
+
return deps.spawnProcess ?? ((cmd, args, opts) => spawn(cmd, args, opts));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function shouldDeliverToPlatform(
|
|
130
|
+
deps: DeliveryDependencies,
|
|
131
|
+
platform: NodeJS.Platform,
|
|
132
|
+
): boolean {
|
|
133
|
+
return (deps.platform ?? process.platform) === platform;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function runDetached(
|
|
137
|
+
spawnFn: SpawnProcess,
|
|
138
|
+
command: string,
|
|
139
|
+
args: string[],
|
|
140
|
+
): void {
|
|
141
|
+
try {
|
|
142
|
+
const child = spawnFn(command, args, { stdio: "ignore" });
|
|
143
|
+
child.unref?.();
|
|
144
|
+
} catch {
|
|
145
|
+
/* spawn failures degrade silently */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function deliverPlatformNotification(
|
|
150
|
+
request: NotificationRequest,
|
|
151
|
+
deps: DeliveryDependencies,
|
|
152
|
+
platform: NodeJS.Platform,
|
|
153
|
+
buildCommand: BuildNotificationCommand,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
if (!shouldDeliverToPlatform(deps, platform)) return;
|
|
156
|
+
const { command, args } = buildCommand(request);
|
|
157
|
+
runDetached(getSpawnProcess(deps), command, args);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function deliverLinuxNotification(
|
|
161
|
+
request: NotificationRequest,
|
|
162
|
+
deps: DeliveryDependencies = {},
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
if (!shouldDeliverToPlatform(deps, "linux")) return;
|
|
165
|
+
const commandExists = deps.commandExists ?? defaultCommandExists;
|
|
166
|
+
const available = await checkNotifySendExists(commandExists);
|
|
167
|
+
if (!available) return;
|
|
168
|
+
runDetached(
|
|
169
|
+
getSpawnProcess(deps),
|
|
170
|
+
LINUX_NOTIFY_SEND,
|
|
171
|
+
escapeLinuxArgs(request),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function deliverMacOSNotification(
|
|
176
|
+
request: NotificationRequest,
|
|
177
|
+
deps: DeliveryDependencies = {},
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
await deliverPlatformNotification(
|
|
180
|
+
request,
|
|
181
|
+
deps,
|
|
182
|
+
"darwin",
|
|
183
|
+
(notification) => ({
|
|
184
|
+
command: "osascript",
|
|
185
|
+
args: ["-e", buildAppleScript(notification)],
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function deliverWindowsNotification(
|
|
191
|
+
request: NotificationRequest,
|
|
192
|
+
deps: DeliveryDependencies = {},
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
await deliverPlatformNotification(request, deps, "win32", (notification) => ({
|
|
195
|
+
command: "powershell.exe",
|
|
196
|
+
args: [
|
|
197
|
+
"-NoProfile",
|
|
198
|
+
"-NonInteractive",
|
|
199
|
+
"-WindowStyle",
|
|
200
|
+
"Hidden",
|
|
201
|
+
"-Command",
|
|
202
|
+
buildPowerShellScript(notification),
|
|
203
|
+
],
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const PLATFORM_DELIVERIES: Partial<
|
|
208
|
+
Record<NodeJS.Platform, NotificationDelivery>
|
|
209
|
+
> = {
|
|
210
|
+
linux: deliverLinuxNotification,
|
|
211
|
+
darwin: deliverMacOSNotification,
|
|
212
|
+
win32: deliverWindowsNotification,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function resolveDeliveryDeps(
|
|
216
|
+
explicit?: DeliveryDependencies,
|
|
217
|
+
): DeliveryDependencies {
|
|
218
|
+
if (!defaultDeliveryDeps) return explicit ?? {};
|
|
219
|
+
if (!explicit) return defaultDeliveryDeps;
|
|
220
|
+
return { ...defaultDeliveryDeps, ...explicit };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function deliverNotification(
|
|
224
|
+
request: NotificationRequest,
|
|
225
|
+
deps?: DeliveryDependencies,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
const resolved = resolveDeliveryDeps(deps);
|
|
228
|
+
const platform = resolved.platform ?? process.platform;
|
|
229
|
+
const deliver = PLATFORM_DELIVERIES[platform];
|
|
230
|
+
if (deliver) await deliver(request, resolved);
|
|
231
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop notification model, formatting, and environment parsing.
|
|
3
|
+
*
|
|
4
|
+
* Defines the notification request shape, message text generation,
|
|
5
|
+
* duration formatting, and opt-out env parsing behind a small testable
|
|
6
|
+
* boundary. No platform delivery logic lives here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatElapsed } from "../progress/progress-format.js";
|
|
10
|
+
import type {
|
|
11
|
+
ProgressStatus,
|
|
12
|
+
SubagentProgressState,
|
|
13
|
+
} from "../progress/progress-state.js";
|
|
14
|
+
|
|
15
|
+
export const NOTIFICATION_TITLE = "Pi Subagent";
|
|
16
|
+
|
|
17
|
+
export interface NotificationRequest {
|
|
18
|
+
title: string;
|
|
19
|
+
body: string;
|
|
20
|
+
urgency: "normal" | "critical";
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
25
|
+
|
|
26
|
+
export function isDesktopNotificationsEnabled(
|
|
27
|
+
env?: Partial<NodeJS.ProcessEnv>,
|
|
28
|
+
): boolean {
|
|
29
|
+
const raw =
|
|
30
|
+
env?.PI_SUBAGENT_DESKTOP_NOTIFICATIONS ??
|
|
31
|
+
process.env.PI_SUBAGENT_DESKTOP_NOTIFICATIONS;
|
|
32
|
+
if (raw === undefined || raw === "") return true;
|
|
33
|
+
return raw !== "0";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isPerJobNotificationEnabled(
|
|
37
|
+
env?: Partial<NodeJS.ProcessEnv>,
|
|
38
|
+
): boolean {
|
|
39
|
+
const raw =
|
|
40
|
+
env?.PI_SUBAGENT_NOTIFY_PER_JOB ?? process.env.PI_SUBAGENT_NOTIFY_PER_JOB;
|
|
41
|
+
return raw === "1";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function deriveDurationMs(
|
|
45
|
+
state: SubagentProgressState,
|
|
46
|
+
now?: () => number,
|
|
47
|
+
): number {
|
|
48
|
+
if (state.durationMs !== undefined) return state.durationMs;
|
|
49
|
+
return (now ? now() : Date.now()) - state.startTime;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatNotificationBody(
|
|
53
|
+
agent: string,
|
|
54
|
+
status: ProgressStatus,
|
|
55
|
+
durationMs: number,
|
|
56
|
+
): string {
|
|
57
|
+
const duration = formatElapsed(durationMs);
|
|
58
|
+
if (status === "error") return `${agent} failed after ${duration}`;
|
|
59
|
+
return `${agent} finished in ${duration}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildNotificationRequest(
|
|
63
|
+
state: SubagentProgressState,
|
|
64
|
+
now?: () => number,
|
|
65
|
+
): NotificationRequest {
|
|
66
|
+
const durationMs = deriveDurationMs(state, now);
|
|
67
|
+
return {
|
|
68
|
+
title: NOTIFICATION_TITLE,
|
|
69
|
+
body: formatNotificationBody(state.agent, state.status, durationMs),
|
|
70
|
+
urgency: state.status === "error" ? "critical" : "normal",
|
|
71
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { startSubagentJob } from "./subagent-orchestrator.js";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
function parseRunArgs(
|
|
8
8
|
args: string,
|
|
9
9
|
): { agentName: string; task: string; debug: boolean } | undefined {
|
|
10
10
|
const input = args.trim();
|