@oh-my-pi/pi-coding-agent 17.0.2 → 17.0.3
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 +70 -20
- package/dist/cli.js +4087 -4108
- package/dist/types/config/settings-schema.d.ts +7 -3
- package/dist/types/eval/agent-bridge.d.ts +7 -19
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +15 -1
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/lsp/types.d.ts +3 -0
- package/dist/types/mcp/transports/stdio.d.ts +29 -0
- package/dist/types/modes/components/agent-hub.d.ts +15 -0
- package/dist/types/registry/persisted-agents.d.ts +3 -0
- package/dist/types/sdk.d.ts +14 -3
- package/dist/types/session/session-entries.d.ts +6 -1
- package/dist/types/session/session-manager.d.ts +5 -0
- package/dist/types/task/executor.d.ts +26 -1
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/parallel.d.ts +14 -0
- package/dist/types/task/structured-subagent.d.ts +111 -0
- package/dist/types/task/types.d.ts +51 -0
- package/dist/types/tools/fetch.d.ts +4 -5
- package/dist/types/tools/hub/messaging.d.ts +5 -1
- package/dist/types/tools/index.d.ts +16 -1
- package/dist/types/tools/todo.d.ts +31 -0
- package/dist/types/tui/tree-list.d.ts +7 -0
- package/dist/types/utils/markit.d.ts +11 -0
- package/package.json +12 -12
- package/src/cli/file-processor.ts +1 -2
- package/src/config/settings-schema.ts +4 -3
- package/src/eval/__tests__/agent-bridge.test.ts +133 -47
- package/src/eval/__tests__/prelude-agent.test.ts +29 -0
- package/src/eval/agent-bridge.ts +104 -477
- package/src/eval/jl/prelude.jl +7 -6
- package/src/eval/js/shared/prelude.txt +5 -4
- package/src/eval/py/prelude.py +11 -39
- package/src/eval/rb/prelude.rb +5 -6
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
- package/src/lsp/client.ts +57 -0
- package/src/lsp/index.ts +62 -6
- package/src/lsp/types.ts +3 -0
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/mcp/transports/stdio.test.ts +269 -1
- package/src/mcp/transports/stdio.ts +152 -1
- package/src/modes/components/agent-hub.ts +1 -72
- package/src/modes/components/bash-execution.ts +7 -3
- package/src/modes/components/eval-execution.ts +3 -1
- package/src/modes/interactive-mode.ts +30 -8
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/task.md +12 -8
- package/src/prompts/tools/write.md +1 -1
- package/src/registry/persisted-agents.ts +74 -0
- package/src/sdk.ts +129 -87
- package/src/session/agent-session.ts +75 -21
- package/src/session/session-entries.ts +6 -1
- package/src/session/session-manager.ts +9 -0
- package/src/session/streaming-output.ts +41 -1
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +99 -21
- package/src/task/index.ts +258 -429
- package/src/task/parallel.ts +43 -0
- package/src/task/persisted-revive.ts +19 -7
- package/src/task/structured-subagent.ts +642 -0
- package/src/task/types.ts +58 -0
- package/src/tools/__tests__/eval-description.test.ts +1 -1
- package/src/tools/fetch.ts +28 -105
- package/src/tools/hub/messaging.ts +16 -3
- package/src/tools/index.ts +47 -14
- package/src/tools/path-utils.ts +1 -0
- package/src/tools/read.ts +14 -26
- package/src/tools/todo.ts +126 -13
- package/src/tui/tree-list.ts +39 -0
- package/src/utils/markit.ts +12 -0
- package/src/utils/zip.ts +16 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it, spyOn } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
2
5
|
|
|
3
|
-
import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
|
|
6
|
+
import { resolveStdioSpawnCommand, StdioTransport, terminateStdioProcess } from "./stdio";
|
|
4
7
|
|
|
5
8
|
describe("resolveStdioSpawnCommand", () => {
|
|
6
9
|
it("hides Windows executable MCP servers when the host has no console", async () => {
|
|
@@ -149,3 +152,268 @@ describe.skipIf(process.platform === "win32")("StdioTransport request write stal
|
|
|
149
152
|
}
|
|
150
153
|
}, 8000);
|
|
151
154
|
});
|
|
155
|
+
|
|
156
|
+
function processExists(pid: number): boolean {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(pid, 0);
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Regression for #5578: `close()` used a bare `this.#process.kill()` (direct
|
|
166
|
+
// SIGTERM, no wait, no escalate, no process-group signal), so a detached
|
|
167
|
+
// session-leader child (or a grandchild it spawned) that ignores/traps
|
|
168
|
+
// SIGTERM survived host exit and became an orphan pinned to PID 1 on Linux.
|
|
169
|
+
// `sleep`/`bun`/POSIX signal semantics are exercised directly here rather
|
|
170
|
+
// than through `StdioTransport.connect()`, because `connect()` derives
|
|
171
|
+
// `detached` from `resolveStdioSpawnCommand()`, which is tied to the host's
|
|
172
|
+
// real `process.platform` — a POSIX detached session cannot be reproduced
|
|
173
|
+
// end-to-end through `connect()` on a non-Linux dev/CI host, but a real
|
|
174
|
+
// detached process group can still be spawned directly on any POSIX host.
|
|
175
|
+
describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => {
|
|
176
|
+
it("escalates a detached child that traps SIGTERM to SIGKILL", async () => {
|
|
177
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-kill-solo-"));
|
|
178
|
+
const scriptPath = path.join(tempDir, "child.mjs");
|
|
179
|
+
const readyPath = path.join(tempDir, "ready");
|
|
180
|
+
await fs.writeFile(
|
|
181
|
+
scriptPath,
|
|
182
|
+
[
|
|
183
|
+
"import { writeFileSync } from 'node:fs';",
|
|
184
|
+
"process.on('SIGTERM', () => {});",
|
|
185
|
+
`writeFileSync(${JSON.stringify(readyPath)}, '1');`,
|
|
186
|
+
"setInterval(() => {}, 60_000);",
|
|
187
|
+
].join("\n"),
|
|
188
|
+
);
|
|
189
|
+
const proc = Bun.spawn(["bun", "run", scriptPath], {
|
|
190
|
+
stdin: "ignore",
|
|
191
|
+
stdout: "ignore",
|
|
192
|
+
stderr: "ignore",
|
|
193
|
+
detached: true,
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
// Wait for the child to actually register its SIGTERM handler before
|
|
197
|
+
// signaling it: signaling too early races the child's startup and
|
|
198
|
+
// hits the default (terminate) action instead of exercising the trap.
|
|
199
|
+
for (let i = 0; i < 100; i++) {
|
|
200
|
+
try {
|
|
201
|
+
await fs.access(readyPath);
|
|
202
|
+
break;
|
|
203
|
+
} catch {
|
|
204
|
+
await Bun.sleep(20);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const started = performance.now();
|
|
209
|
+
await terminateStdioProcess(proc, true);
|
|
210
|
+
await proc.exited;
|
|
211
|
+
const elapsedMs = performance.now() - started;
|
|
212
|
+
|
|
213
|
+
expect(proc.signalCode).toBe("SIGKILL");
|
|
214
|
+
// Escalation only fires after the ~1s SIGTERM grace window elapses —
|
|
215
|
+
// a too-fast exit would mean SIGKILL fired without waiting.
|
|
216
|
+
expect(elapsedMs).toBeGreaterThanOrEqual(900);
|
|
217
|
+
} finally {
|
|
218
|
+
try {
|
|
219
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
220
|
+
} catch {
|
|
221
|
+
// Already gone.
|
|
222
|
+
}
|
|
223
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
}, 5000);
|
|
226
|
+
|
|
227
|
+
it("reaches a SIGTERM-trapping grandchild through the group SIGKILL, not just the direct child", async () => {
|
|
228
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-group-kill-"));
|
|
229
|
+
const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
|
|
230
|
+
const parentScriptPath = path.join(tempDir, "parent.mjs");
|
|
231
|
+
const grandchildPidPath = path.join(tempDir, "grandchild.pid");
|
|
232
|
+
|
|
233
|
+
// Grandchild also traps SIGTERM, so only an unrestricted SIGKILL to the
|
|
234
|
+
// whole group — not a signal to the direct (parent) child alone — can
|
|
235
|
+
// reach and stop it.
|
|
236
|
+
await fs.writeFile(
|
|
237
|
+
grandchildScriptPath,
|
|
238
|
+
[
|
|
239
|
+
"import { writeFileSync } from 'node:fs';",
|
|
240
|
+
"process.on('SIGTERM', () => {});",
|
|
241
|
+
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
|
|
242
|
+
"setInterval(() => {}, 60_000);",
|
|
243
|
+
].join("\n"),
|
|
244
|
+
);
|
|
245
|
+
await fs.writeFile(
|
|
246
|
+
parentScriptPath,
|
|
247
|
+
[
|
|
248
|
+
"process.on('SIGTERM', () => {});",
|
|
249
|
+
`Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
|
|
250
|
+
"setInterval(() => {}, 60_000);",
|
|
251
|
+
].join("\n"),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const proc = Bun.spawn(["bun", "run", parentScriptPath], {
|
|
255
|
+
stdin: "ignore",
|
|
256
|
+
stdout: "ignore",
|
|
257
|
+
stderr: "ignore",
|
|
258
|
+
detached: true,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Polls real wall-clock time rather than an event/promise: the
|
|
263
|
+
// grandchild process is a real external OS process writing to a real
|
|
264
|
+
// file, with no in-process signal this test can `await` directly.
|
|
265
|
+
let grandchildPid: number | undefined;
|
|
266
|
+
for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
|
|
267
|
+
try {
|
|
268
|
+
grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
|
|
269
|
+
} catch {
|
|
270
|
+
await Bun.sleep(20);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
|
|
274
|
+
expect(processExists(grandchildPid)).toBe(true);
|
|
275
|
+
|
|
276
|
+
await terminateStdioProcess(proc, true);
|
|
277
|
+
await proc.exited;
|
|
278
|
+
expect(proc.signalCode).toBe("SIGKILL");
|
|
279
|
+
|
|
280
|
+
// The group SIGKILL is delivered to every member simultaneously, but
|
|
281
|
+
// give the kernel a brief window to finish reaping before asserting.
|
|
282
|
+
let grandchildAlive = processExists(grandchildPid);
|
|
283
|
+
for (let i = 0; i < 25 && grandchildAlive; i++) {
|
|
284
|
+
await Bun.sleep(20);
|
|
285
|
+
grandchildAlive = processExists(grandchildPid);
|
|
286
|
+
}
|
|
287
|
+
expect(grandchildAlive).toBe(false);
|
|
288
|
+
} finally {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
291
|
+
} catch {
|
|
292
|
+
// Already gone.
|
|
293
|
+
}
|
|
294
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
295
|
+
}
|
|
296
|
+
}, 8000);
|
|
297
|
+
|
|
298
|
+
it("still reaps a SIGTERM-trapping grandchild after the detached leader exits cooperatively", async () => {
|
|
299
|
+
// Regression: the leader exiting within the SIGTERM grace window used to
|
|
300
|
+
// be treated as proof the whole process group was gone, so `close()`
|
|
301
|
+
// returned early and never delivered a group SIGKILL — leaving exactly
|
|
302
|
+
// the orphaned grandchild this change is meant to reap. Unlike the
|
|
303
|
+
// group-SIGKILL test above, the leader here does NOT trap SIGTERM, so
|
|
304
|
+
// it exits promptly on its own; only the grandchild ignores signals.
|
|
305
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-leader-exit-group-kill-"));
|
|
306
|
+
const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
|
|
307
|
+
const parentScriptPath = path.join(tempDir, "parent.mjs");
|
|
308
|
+
const grandchildPidPath = path.join(tempDir, "grandchild.pid");
|
|
309
|
+
|
|
310
|
+
await fs.writeFile(
|
|
311
|
+
grandchildScriptPath,
|
|
312
|
+
[
|
|
313
|
+
"import { writeFileSync } from 'node:fs';",
|
|
314
|
+
"process.on('SIGTERM', () => {});",
|
|
315
|
+
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
|
|
316
|
+
"setInterval(() => {}, 60_000);",
|
|
317
|
+
].join("\n"),
|
|
318
|
+
);
|
|
319
|
+
// No SIGTERM handler here: the default action (terminate) fires as soon
|
|
320
|
+
// as the group SIGTERM lands, well inside TERM_GRACE_MS.
|
|
321
|
+
await fs.writeFile(
|
|
322
|
+
parentScriptPath,
|
|
323
|
+
[
|
|
324
|
+
`Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
|
|
325
|
+
"setInterval(() => {}, 60_000);",
|
|
326
|
+
].join("\n"),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const proc = Bun.spawn(["bun", "run", parentScriptPath], {
|
|
330
|
+
stdin: "ignore",
|
|
331
|
+
stdout: "ignore",
|
|
332
|
+
stderr: "ignore",
|
|
333
|
+
detached: true,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
let grandchildPid: number | undefined;
|
|
338
|
+
for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
|
|
339
|
+
try {
|
|
340
|
+
grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
|
|
341
|
+
} catch {
|
|
342
|
+
await Bun.sleep(20);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
|
|
346
|
+
expect(processExists(grandchildPid)).toBe(true);
|
|
347
|
+
|
|
348
|
+
const started = performance.now();
|
|
349
|
+
await terminateStdioProcess(proc, true);
|
|
350
|
+
await proc.exited;
|
|
351
|
+
const elapsedMs = performance.now() - started;
|
|
352
|
+
|
|
353
|
+
// The leader exits on the initial SIGTERM (no trap), so this must not
|
|
354
|
+
// block for the ~1s TERM grace window before sweeping the group.
|
|
355
|
+
expect(elapsedMs).toBeLessThan(700);
|
|
356
|
+
|
|
357
|
+
let grandchildAlive = processExists(grandchildPid);
|
|
358
|
+
for (let i = 0; i < 25 && grandchildAlive; i++) {
|
|
359
|
+
await Bun.sleep(20);
|
|
360
|
+
grandchildAlive = processExists(grandchildPid);
|
|
361
|
+
}
|
|
362
|
+
expect(grandchildAlive).toBe(false);
|
|
363
|
+
} finally {
|
|
364
|
+
try {
|
|
365
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
366
|
+
} catch {
|
|
367
|
+
// Already gone.
|
|
368
|
+
}
|
|
369
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
370
|
+
}
|
|
371
|
+
}, 8000);
|
|
372
|
+
|
|
373
|
+
it("never attempts a process-group signal when the transport did not spawn detached", async () => {
|
|
374
|
+
const proc = Bun.spawn(["bun", "-e", "await Bun.sleep(60_000)"], {
|
|
375
|
+
stdin: "ignore",
|
|
376
|
+
stdout: "ignore",
|
|
377
|
+
stderr: "ignore",
|
|
378
|
+
detached: false,
|
|
379
|
+
});
|
|
380
|
+
const killSpy = spyOn(process, "kill");
|
|
381
|
+
try {
|
|
382
|
+
await terminateStdioProcess(proc, false);
|
|
383
|
+
await proc.exited;
|
|
384
|
+
|
|
385
|
+
// Only the direct-child `Subprocess.kill()` path may run; the global
|
|
386
|
+
// `process.kill()` (used exclusively for the negative-pid group
|
|
387
|
+
// signal) must never be reached.
|
|
388
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
389
|
+
} finally {
|
|
390
|
+
killSpy.mockRestore();
|
|
391
|
+
try {
|
|
392
|
+
proc.kill("SIGKILL");
|
|
393
|
+
} catch {
|
|
394
|
+
// Already gone.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}, 5000);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe.skipIf(process.platform === "win32")("StdioTransport.close teardown", () => {
|
|
401
|
+
it("closes a well-behaved child promptly without escalating to SIGKILL", async () => {
|
|
402
|
+
const transport = new StdioTransport({
|
|
403
|
+
command: "bun",
|
|
404
|
+
args: ["-e", "await Bun.sleep(60_000)"],
|
|
405
|
+
});
|
|
406
|
+
try {
|
|
407
|
+
await transport.connect();
|
|
408
|
+
const started = performance.now();
|
|
409
|
+
await transport.close();
|
|
410
|
+
const elapsedMs = performance.now() - started;
|
|
411
|
+
|
|
412
|
+
// No SIGTERM trap => the child dies almost immediately; close() must
|
|
413
|
+
// not block for the full ~1s TERM grace window before returning.
|
|
414
|
+
expect(elapsedMs).toBeLessThan(700);
|
|
415
|
+
} finally {
|
|
416
|
+
await transport.close();
|
|
417
|
+
}
|
|
418
|
+
}, 5000);
|
|
419
|
+
});
|
|
@@ -404,6 +404,132 @@ export function writeFrame(stdin: FrameSink, frame: string): boolean {
|
|
|
404
404
|
}
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
/** Grace window to observe a cooperative exit after SIGTERM before escalating to SIGKILL. */
|
|
408
|
+
const TERM_GRACE_MS = 1000;
|
|
409
|
+
/** Grace window to observe SIGKILL taking effect before `close()` gives up and returns. */
|
|
410
|
+
const KILL_GRACE_MS = 500;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The subset of `Subprocess` that termination needs. Decoupled from the
|
|
414
|
+
* `Subprocess<In, Out, Err>` stdio generics — `#process`'s pipes are
|
|
415
|
+
* irrelevant to signaling — so tests can exercise it against a plain
|
|
416
|
+
* `Bun.spawn(cmd, { stdio: "ignore" })` child without fighting the generics.
|
|
417
|
+
*/
|
|
418
|
+
interface KillableSubprocess {
|
|
419
|
+
readonly pid: number;
|
|
420
|
+
readonly exited: Promise<number>;
|
|
421
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Race `exited` against a timer. Resolves `true` once the process has exited
|
|
426
|
+
* within `timeoutMs`, `false` if the timer wins first. `exited` resolving OR
|
|
427
|
+
* rejecting both count as "exited" — mirrors `waitForExit()` in
|
|
428
|
+
* `lsp/client.ts`, which treats the same ambiguity (Bun documents
|
|
429
|
+
* `Subprocess.exited` as resolve-only, but a settle either way means there is
|
|
430
|
+
* nothing left to wait on).
|
|
431
|
+
*
|
|
432
|
+
* The timer is always cleared before returning — win or lose — so a process
|
|
433
|
+
* that exits promptly never leaves a dangling `timeoutMs` timer holding the
|
|
434
|
+
* event loop open behind it.
|
|
435
|
+
*/
|
|
436
|
+
async function waitForProcessExit(exited: Promise<number>, timeoutMs: number): Promise<boolean> {
|
|
437
|
+
const { promise: timedOut, resolve: resolveTimedOut } = Promise.withResolvers<false>();
|
|
438
|
+
const timer = setTimeout(() => resolveTimedOut(false), timeoutMs);
|
|
439
|
+
try {
|
|
440
|
+
return await Promise.race([
|
|
441
|
+
exited.then(
|
|
442
|
+
() => true,
|
|
443
|
+
() => true,
|
|
444
|
+
),
|
|
445
|
+
timedOut,
|
|
446
|
+
]);
|
|
447
|
+
} finally {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** `true` when `error` is a Node errno exception carrying the given `code`. */
|
|
453
|
+
function isErrnoCode(error: unknown, code: string): boolean {
|
|
454
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
455
|
+
return error.code === code;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Signal `signal` to `proc`. When `detached` is true on a POSIX platform,
|
|
460
|
+
* targets the whole process group via the negative-pid convention
|
|
461
|
+
* (`process.kill(-pid, signal)`) so a detached session leader's descendants —
|
|
462
|
+
* not just the direct child — receive it too; a bare direct-child signal
|
|
463
|
+
* never reaches grandchildren the child itself spawned.
|
|
464
|
+
*
|
|
465
|
+
* `ESRCH` from the group signal means the group is already gone — that is a
|
|
466
|
+
* success (nothing left to signal), not a failure — so it does not fall
|
|
467
|
+
* through. Any other group-signal failure (e.g. `EPERM`) falls back to
|
|
468
|
+
* signaling the direct child as a last resort. Non-detached transports
|
|
469
|
+
* (macOS, Windows, or POSIX where detach did not apply) always signal the
|
|
470
|
+
* direct child only: a negative-pid signal outside a detached session could
|
|
471
|
+
* hit an unrelated process group.
|
|
472
|
+
*/
|
|
473
|
+
function signalStdioProcess(
|
|
474
|
+
proc: KillableSubprocess,
|
|
475
|
+
detached: boolean,
|
|
476
|
+
signal: NodeJS.Signals,
|
|
477
|
+
platform: NodeJS.Platform,
|
|
478
|
+
): void {
|
|
479
|
+
if (detached && platform !== "win32") {
|
|
480
|
+
try {
|
|
481
|
+
process.kill(-proc.pid, signal);
|
|
482
|
+
return;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (isErrnoCode(error, "ESRCH")) return;
|
|
485
|
+
// Fall through to the direct-child signal below.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
proc.kill(signal);
|
|
490
|
+
} catch {
|
|
491
|
+
// Already gone.
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached`
|
|
497
|
+
* on POSIX, direct child otherwise), wait up to `TERM_GRACE_MS` for a
|
|
498
|
+
* cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS`
|
|
499
|
+
* more only when the leader itself hadn't already exited. A detached
|
|
500
|
+
* leader's cooperative exit does not prove the whole process group is gone
|
|
501
|
+
* (a grandchild can outlive it and ignore SIGTERM), so detached transports
|
|
502
|
+
* always fire the group SIGKILL sweep, even after a clean SIGTERM exit.
|
|
503
|
+
* Every step is a no-op-safe signal against an already-exited target, so
|
|
504
|
+
* repeat calls (idempotent `close()`) never throw.
|
|
505
|
+
*
|
|
506
|
+
* Exported so tests can exercise group-signal escalation with an explicit
|
|
507
|
+
* `detached`/`platform` pair: `StdioTransport.connect()` derives `detached`
|
|
508
|
+
* from `resolveStdioSpawnCommand()`, which is tied to the host's real
|
|
509
|
+
* `process.platform`, so a POSIX detached session cannot be reproduced
|
|
510
|
+
* end-to-end through `connect()` on a non-Linux dev/CI host.
|
|
511
|
+
*/
|
|
512
|
+
export async function terminateStdioProcess(
|
|
513
|
+
proc: KillableSubprocess,
|
|
514
|
+
detached: boolean,
|
|
515
|
+
platform: NodeJS.Platform = process.platform,
|
|
516
|
+
): Promise<void> {
|
|
517
|
+
signalStdioProcess(proc, detached, "SIGTERM", platform);
|
|
518
|
+
const exitedOnTerm = await waitForProcessExit(proc.exited, TERM_GRACE_MS);
|
|
519
|
+
// A non-detached transport has no process group beyond the leader itself:
|
|
520
|
+
// once it exits, there is nothing left to signal. A detached transport's
|
|
521
|
+
// leader exiting is NOT proof the group is empty — a grandchild it spawned
|
|
522
|
+
// can still be alive and ignoring SIGTERM — so detached transports always
|
|
523
|
+
// fall through to the group SIGKILL, even on a cooperative leader exit.
|
|
524
|
+
if (exitedOnTerm && !detached) return;
|
|
525
|
+
signalStdioProcess(proc, detached, "SIGKILL", platform);
|
|
526
|
+
// Once the leader has already exited there is no further `exited` signal
|
|
527
|
+
// to wait on for this call — the SIGKILL above is a fire-and-forget sweep
|
|
528
|
+
// for any surviving group members — so only block on the grace window
|
|
529
|
+
// when the leader itself is still the thing being escalated against.
|
|
530
|
+
if (!exitedOnTerm) await waitForProcessExit(proc.exited, KILL_GRACE_MS);
|
|
531
|
+
}
|
|
532
|
+
|
|
407
533
|
/**
|
|
408
534
|
* Stdio transport for MCP servers.
|
|
409
535
|
* Spawns a subprocess and communicates via stdin/stdout.
|
|
@@ -419,6 +545,12 @@ export class StdioTransport implements MCPTransport {
|
|
|
419
545
|
>();
|
|
420
546
|
#connected = false;
|
|
421
547
|
#readLoop: Promise<void> | null = null;
|
|
548
|
+
/**
|
|
549
|
+
* Set from `resolveStdioSpawnCommand()`'s `detached` flag in `connect()`.
|
|
550
|
+
* Gates process-group signaling in `close()` — only a transport that
|
|
551
|
+
* actually spawned into its own session may target it.
|
|
552
|
+
*/
|
|
553
|
+
#detached = false;
|
|
422
554
|
|
|
423
555
|
onClose?: () => void;
|
|
424
556
|
onError?: (error: Error) => void;
|
|
@@ -469,6 +601,7 @@ export class StdioTransport implements MCPTransport {
|
|
|
469
601
|
detached: spawnCommand.detached,
|
|
470
602
|
windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
|
|
471
603
|
});
|
|
604
|
+
this.#detached = spawnCommand.detached;
|
|
472
605
|
|
|
473
606
|
this.#connected = true;
|
|
474
607
|
|
|
@@ -730,8 +863,26 @@ export class StdioTransport implements MCPTransport {
|
|
|
730
863
|
}
|
|
731
864
|
|
|
732
865
|
if (this.#process) {
|
|
733
|
-
|
|
866
|
+
// Grab the handle and null the field immediately (before any
|
|
867
|
+
// `await`) so a concurrent/repeat `close()` sees `#process` already
|
|
868
|
+
// cleared and skips straight past this block — no double-signal.
|
|
869
|
+
const proc = this.#process;
|
|
734
870
|
this.#process = null;
|
|
871
|
+
|
|
872
|
+
// 1. Cooperative EOF first: a well-behaved server sees stdin close
|
|
873
|
+
// and can exit on its own before any signal is sent. Guarded — the
|
|
874
|
+
// sink can throw if the pipe is already closed/dead (e.g. the child
|
|
875
|
+
// already exited and the read loop got there first).
|
|
876
|
+
try {
|
|
877
|
+
proc.stdin.end();
|
|
878
|
+
} catch {
|
|
879
|
+
// Already closed/dead.
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 2-3. Group-aware SIGTERM (when this transport actually spawned
|
|
883
|
+
// detached), bounded wait, then escalate to SIGKILL. See
|
|
884
|
+
// `terminateStdioProcess` for the exact signaling/escalation rules.
|
|
885
|
+
await terminateStdioProcess(proc, this.#detached);
|
|
735
886
|
}
|
|
736
887
|
|
|
737
888
|
if (this.#readLoop) {
|
|
@@ -13,17 +13,15 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
|
|
15
15
|
*/
|
|
16
|
-
import * as fs from "node:fs";
|
|
17
|
-
import * as path from "node:path";
|
|
18
16
|
import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
19
17
|
import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
20
18
|
import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
21
|
-
import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../../advisor";
|
|
22
19
|
import type { KeyId } from "../../config/keybindings";
|
|
23
20
|
import type { MessageRenderer } from "../../extensibility/extensions/types";
|
|
24
21
|
import { IrcBus } from "../../irc/bus";
|
|
25
22
|
import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
|
26
23
|
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
|
24
|
+
import { registerPersistedSubagents } from "../../registry/persisted-agents";
|
|
27
25
|
import { USER_INTERRUPT_LABEL } from "../../session/messages";
|
|
28
26
|
import { parseThinkingLevel } from "../../thinking";
|
|
29
27
|
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
|
@@ -100,75 +98,6 @@ function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): str
|
|
|
100
98
|
return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
|
|
101
99
|
}
|
|
102
100
|
|
|
103
|
-
async function registerPersistedSubagents(
|
|
104
|
-
registry: AgentRegistry,
|
|
105
|
-
sessionFile: string | null | undefined,
|
|
106
|
-
): Promise<void> {
|
|
107
|
-
if (!sessionFile?.endsWith(".jsonl")) return;
|
|
108
|
-
const root = sessionFile.slice(0, -6);
|
|
109
|
-
await registerPersistedSubagentsFromDir(registry, root, undefined);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function registerPersistedSubagentsFromDir(
|
|
113
|
-
registry: AgentRegistry,
|
|
114
|
-
dir: string,
|
|
115
|
-
parentId: string | undefined,
|
|
116
|
-
): Promise<void> {
|
|
117
|
-
let entries: fs.Dirent[];
|
|
118
|
-
try {
|
|
119
|
-
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
120
|
-
} catch {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
for (const entry of entries) {
|
|
124
|
-
if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue;
|
|
125
|
-
const sessionFile = path.join(dir, entry.name);
|
|
126
|
-
// The advisor transcript is observability-only: register it as a non-peer
|
|
127
|
-
// `advisor` kind under its owning session so the Hub can show its read-only
|
|
128
|
-
// transcript, but it never joins agent-facing rosters and is not revivable.
|
|
129
|
-
if (isAdvisorTranscriptName(entry.name)) {
|
|
130
|
-
const owner = parentId ?? MAIN_AGENT_ID;
|
|
131
|
-
// `__advisor.jsonl` → the default advisor (no slug); `__advisor.<slug>.jsonl`
|
|
132
|
-
// → a named advisor, keyed and labeled by its slug.
|
|
133
|
-
const slug =
|
|
134
|
-
entry.name === ADVISOR_TRANSCRIPT_FILENAME ? "" : entry.name.slice("__advisor.".length, -".jsonl".length);
|
|
135
|
-
const advisorId = slug ? `${owner}/advisor:${slug}` : `${owner}/advisor`;
|
|
136
|
-
const displayName = slug ? `advisor:${slug}` : "advisor";
|
|
137
|
-
const existing = registry.get(advisorId);
|
|
138
|
-
// Never clobber a non-advisor ref that happens to share this id (a freak
|
|
139
|
-
// user task literally named `<owner>/advisor`): leave it, skip the advisor.
|
|
140
|
-
if (existing && existing.kind !== "advisor") continue;
|
|
141
|
-
if (existing?.sessionFile !== sessionFile) {
|
|
142
|
-
// The id is reused across `/new`; refresh it to the current session's file.
|
|
143
|
-
if (existing) registry.unregister(advisorId);
|
|
144
|
-
registry.register({
|
|
145
|
-
id: advisorId,
|
|
146
|
-
displayName,
|
|
147
|
-
kind: "advisor",
|
|
148
|
-
parentId: owner,
|
|
149
|
-
session: null,
|
|
150
|
-
sessionFile,
|
|
151
|
-
status: "parked",
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
const id = entry.name.slice(0, -6);
|
|
157
|
-
if (!registry.get(id)) {
|
|
158
|
-
registry.register({
|
|
159
|
-
id,
|
|
160
|
-
displayName: id,
|
|
161
|
-
kind: "sub",
|
|
162
|
-
parentId: parentId ?? MAIN_AGENT_ID,
|
|
163
|
-
session: null,
|
|
164
|
-
sessionFile,
|
|
165
|
-
status: "parked",
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
await registerPersistedSubagentsFromDir(registry, path.join(dir, id), id);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
101
|
/** Result of one host-backed transcript read for the Agent Hub viewer. */
|
|
173
102
|
export interface AgentHubRemoteTranscript {
|
|
174
103
|
text: string;
|
|
@@ -146,14 +146,18 @@ export class BashExecutionComponent extends Container {
|
|
|
146
146
|
#updateDisplay(): void {
|
|
147
147
|
const availableLines = this.#outputLines;
|
|
148
148
|
|
|
149
|
-
//
|
|
149
|
+
// Full output is shown when expanded or when sixel passthrough renders
|
|
150
|
+
// the raw payload; the collapsed preview shows only the tail window.
|
|
150
151
|
const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
|
|
151
|
-
const hiddenLineCount = availableLines.length - previewLogicalLines.length;
|
|
152
152
|
const sixelLineMask =
|
|
153
153
|
TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
|
|
154
154
|
? getSixelLineMask(availableLines)
|
|
155
155
|
: undefined;
|
|
156
156
|
const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
|
|
157
|
+
const showingAllLines = this.#expanded || hasSixelOutput;
|
|
158
|
+
// Only the collapsed preview hides lines; when the full output is shown
|
|
159
|
+
// the footer must not keep advertising hidden lines / ctrl+o.
|
|
160
|
+
const hiddenLineCount = showingAllLines ? 0 : availableLines.length - previewLogicalLines.length;
|
|
157
161
|
|
|
158
162
|
// Rebuild content container
|
|
159
163
|
this.#contentContainer.clear();
|
|
@@ -163,7 +167,7 @@ export class BashExecutionComponent extends Container {
|
|
|
163
167
|
|
|
164
168
|
// Output
|
|
165
169
|
if (availableLines.length > 0) {
|
|
166
|
-
if (
|
|
170
|
+
if (showingAllLines) {
|
|
167
171
|
const displayText = availableLines
|
|
168
172
|
.map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
|
|
169
173
|
.join("\n");
|
|
@@ -114,7 +114,9 @@ export class EvalExecutionComponent extends Container {
|
|
|
114
114
|
#updateDisplay(): void {
|
|
115
115
|
const availableLines = this.#outputLines;
|
|
116
116
|
const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
|
|
117
|
-
|
|
117
|
+
// Only the collapsed preview hides lines; when expanded the footer must
|
|
118
|
+
// not keep advertising hidden lines / ctrl+o.
|
|
119
|
+
const hiddenLineCount = this.#expanded ? 0 : availableLines.length - previewLogicalLines.length;
|
|
118
120
|
|
|
119
121
|
this.#contentContainer.clear();
|
|
120
122
|
|
|
@@ -123,7 +123,12 @@ import type { LspStartupServerInfo } from "../tools";
|
|
|
123
123
|
import { normalizeLocalScheme } from "../tools/path-utils";
|
|
124
124
|
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
|
|
125
125
|
import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
|
|
126
|
-
import {
|
|
126
|
+
import {
|
|
127
|
+
formatPhaseDisplayName,
|
|
128
|
+
selectCollapsedTodos,
|
|
129
|
+
setActiveTodoDescriptionsProvider,
|
|
130
|
+
todoMatchesAnyDescription,
|
|
131
|
+
} from "../tools/todo";
|
|
127
132
|
import { ToolError } from "../tools/tool-errors";
|
|
128
133
|
import { vocalizer } from "../tts/vocalizer";
|
|
129
134
|
import { renderTreeList } from "../tui/tree-list";
|
|
@@ -951,6 +956,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
951
956
|
this.#observerRegistry.onChange(kind => {
|
|
952
957
|
this.#scheduleObserverUiSync(kind);
|
|
953
958
|
});
|
|
959
|
+
// Let the transient todo tool result light up pending todos executed by a
|
|
960
|
+
// live subagent, matching the sticky HUD's active set (#5873).
|
|
961
|
+
setActiveTodoDescriptionsProvider(() => this.#getActiveSubagentDescriptions());
|
|
954
962
|
|
|
955
963
|
// Load initial todos
|
|
956
964
|
await this.#loadTodoList();
|
|
@@ -1895,15 +1903,29 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1895
1903
|
const isMatched = (todo: TodoItem): boolean =>
|
|
1896
1904
|
activeDescs.length > 0 && todoMatchesAnyDescription(todo.content, activeDescs);
|
|
1897
1905
|
|
|
1898
|
-
// Task subtree for a phase. Collapsed
|
|
1899
|
-
//
|
|
1900
|
-
//
|
|
1906
|
+
// Task subtree for a phase. Collapsed runs the shared walking-viewport
|
|
1907
|
+
// policy (completed/abandoned omitted, active work pulled to the head,
|
|
1908
|
+
// then following pending tasks) so the HUD and the transient tool result
|
|
1909
|
+
// can never disagree about the current work (#5873). Expanded lists all.
|
|
1901
1910
|
const renderTasks = (phase: TodoPhase): string[] => {
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1911
|
+
if (expanded) {
|
|
1912
|
+
return renderTreeList(
|
|
1913
|
+
{
|
|
1914
|
+
items: phase.tasks,
|
|
1915
|
+
expanded: true,
|
|
1916
|
+
renderItem: todo => this.#formatTodoLine(todo, "", isMatched(todo)),
|
|
1917
|
+
},
|
|
1918
|
+
theme,
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
const selection = selectCollapsedTodos(phase.tasks, isMatched, activeTaskCap);
|
|
1905
1922
|
return renderTreeList(
|
|
1906
|
-
{
|
|
1923
|
+
{
|
|
1924
|
+
items: selection.items,
|
|
1925
|
+
itemType: "task",
|
|
1926
|
+
trailingSummary: selection.summary,
|
|
1927
|
+
renderItem: todo => this.#formatTodoLine(todo, "", isMatched(todo)),
|
|
1928
|
+
},
|
|
1907
1929
|
theme,
|
|
1908
1930
|
);
|
|
1909
1931
|
};
|
|
@@ -57,7 +57,7 @@ Special URLs for internal resources; with most FS/bash tools they auto-resolve t
|
|
|
57
57
|
- `memory://root`: project memory summary
|
|
58
58
|
{{/if}}
|
|
59
59
|
- `agent://<id>`: agent output artifact; `/<child>` reads a nested subagent's output, else `/<path>` extracts a JSON field
|
|
60
|
-
- `history://<id>`: read-only markdown transcript of an agent (live, parked, or released); bare `history://` lists all agents. Serves
|
|
60
|
+
- `history://<id>`: read-only markdown transcript of an agent (live, parked, or released); bare `history://` lists all agents. Serves registered agents process-wide plus persisted subagents discoverable from their artifact trees; does not discover unregistered top-level sessions solely from their persisted session files.
|
|
61
61
|
- `artifact://<id>`: artifact content
|
|
62
62
|
- `local://<name>.md`: plan artifacts or shared content for subagents
|
|
63
63
|
{{#if hasObsidian}}
|