@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +70 -20
  2. package/dist/cli.js +4087 -4108
  3. package/dist/types/config/settings-schema.d.ts +7 -3
  4. package/dist/types/eval/agent-bridge.d.ts +7 -19
  5. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +15 -1
  6. package/dist/types/lsp/client.d.ts +2 -0
  7. package/dist/types/lsp/types.d.ts +3 -0
  8. package/dist/types/mcp/transports/stdio.d.ts +29 -0
  9. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  10. package/dist/types/registry/persisted-agents.d.ts +3 -0
  11. package/dist/types/sdk.d.ts +14 -3
  12. package/dist/types/session/session-entries.d.ts +6 -1
  13. package/dist/types/session/session-manager.d.ts +5 -0
  14. package/dist/types/task/executor.d.ts +26 -1
  15. package/dist/types/task/index.d.ts +1 -1
  16. package/dist/types/task/parallel.d.ts +14 -0
  17. package/dist/types/task/structured-subagent.d.ts +111 -0
  18. package/dist/types/task/types.d.ts +51 -0
  19. package/dist/types/tools/fetch.d.ts +4 -5
  20. package/dist/types/tools/hub/messaging.d.ts +5 -1
  21. package/dist/types/tools/index.d.ts +16 -1
  22. package/dist/types/tools/todo.d.ts +31 -0
  23. package/dist/types/tui/tree-list.d.ts +7 -0
  24. package/dist/types/utils/markit.d.ts +11 -0
  25. package/package.json +12 -12
  26. package/src/cli/file-processor.ts +1 -2
  27. package/src/config/settings-schema.ts +4 -3
  28. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  29. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  30. package/src/eval/agent-bridge.ts +104 -477
  31. package/src/eval/jl/prelude.jl +7 -6
  32. package/src/eval/js/shared/prelude.txt +5 -4
  33. package/src/eval/py/prelude.py +11 -39
  34. package/src/eval/rb/prelude.rb +5 -6
  35. package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
  36. package/src/lsp/client.ts +57 -0
  37. package/src/lsp/index.ts +62 -6
  38. package/src/lsp/types.ts +3 -0
  39. package/src/mcp/oauth-flow.ts +20 -0
  40. package/src/mcp/transports/stdio.test.ts +269 -1
  41. package/src/mcp/transports/stdio.ts +152 -1
  42. package/src/modes/components/agent-hub.ts +1 -72
  43. package/src/modes/components/bash-execution.ts +7 -3
  44. package/src/modes/components/eval-execution.ts +3 -1
  45. package/src/modes/interactive-mode.ts +30 -8
  46. package/src/prompts/system/system-prompt.md +1 -1
  47. package/src/prompts/tools/eval.md +5 -2
  48. package/src/prompts/tools/read.md +1 -1
  49. package/src/prompts/tools/task.md +12 -8
  50. package/src/prompts/tools/write.md +1 -1
  51. package/src/registry/persisted-agents.ts +74 -0
  52. package/src/sdk.ts +129 -87
  53. package/src/session/agent-session.ts +75 -21
  54. package/src/session/session-entries.ts +6 -1
  55. package/src/session/session-manager.ts +9 -0
  56. package/src/session/streaming-output.ts +41 -1
  57. package/src/system-prompt.ts +7 -2
  58. package/src/task/executor.ts +99 -21
  59. package/src/task/index.ts +258 -429
  60. package/src/task/parallel.ts +43 -0
  61. package/src/task/persisted-revive.ts +19 -7
  62. package/src/task/structured-subagent.ts +642 -0
  63. package/src/task/types.ts +58 -0
  64. package/src/tools/__tests__/eval-description.test.ts +1 -1
  65. package/src/tools/fetch.ts +28 -105
  66. package/src/tools/hub/messaging.ts +16 -3
  67. package/src/tools/index.ts +47 -14
  68. package/src/tools/path-utils.ts +1 -0
  69. package/src/tools/read.ts +14 -26
  70. package/src/tools/todo.ts +126 -13
  71. package/src/tui/tree-list.ts +39 -0
  72. package/src/utils/markit.ts +12 -0
  73. 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
- this.#process.kill();
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
- // Apply preview truncation based on expanded state
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 (this.#expanded || hasSixelOutput) {
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
- const hiddenLineCount = availableLines.length - previewLogicalLines.length;
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 { formatPhaseDisplayName, todoMatchesAnyDescription } from "../tools/todo";
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 previews the first open tasks — the
1899
- // stage's `done/total` makes the hidden count obvious, so there is no
1900
- // "… more" row; expanded lists every task.
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
- const open = phase.tasks.filter(t => t.status === "pending" || t.status === "in_progress");
1903
- const base = expanded ? phase.tasks : open.length > 0 ? open : phase.tasks;
1904
- const items = expanded ? base : base.slice(0, activeTaskCap);
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
- { items, expanded: true, renderItem: todo => this.#formatTodoLine(todo, "", isMatched(todo)) },
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 any agent whose session file persists on disk, not just registered peers.
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}}