@esso0428/pi-subagents 0.15.1 → 0.15.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/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.2] - 2026-09-10
11
+
12
+ ### Added
13
+ - **Completed subagent records survive session reloads.** On `session_start`, the extension now reconstructs validated terminal records persisted as `subagents:record` entries. Restored records retain their result and status for `get_subagent_result`, but never pretend to have a live child session; running and queued records are ignored, and duplicate IDs use the newest branch entry.
14
+
10
15
  ## [0.15.1] - 2026-09-10
11
16
 
12
17
  ### Added
@@ -121,6 +121,14 @@ export declare class AgentManager {
121
121
  */
122
122
  steer(id: string, message: string): boolean;
123
123
  getRecord(id: string): AgentRecord | undefined;
124
+ /**
125
+ * Restore terminal records persisted in a parent session.
126
+ *
127
+ * Restored records deliberately have no live session, promise, or abort
128
+ * controller. Invalid data is ignored because session entries are persisted
129
+ * extension data and may have been written by an older version.
130
+ */
131
+ restoreCompleted(records: readonly unknown[]): void;
124
132
  listAgents(): AgentRecord[];
125
133
  abort(id: string): boolean;
126
134
  /** Dispose a record's session and remove it from the map. */
@@ -16,6 +16,13 @@ const usage_js_1 = require("./usage.js");
16
16
  const worktree_js_1 = require("./worktree.js");
17
17
  /** Default max concurrent background agents. */
18
18
  const DEFAULT_MAX_CONCURRENT = 4;
19
+ const TERMINAL_STATUSES = new Set([
20
+ "completed",
21
+ "steered",
22
+ "aborted",
23
+ "stopped",
24
+ "error",
25
+ ]);
19
26
  /**
20
27
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
21
28
  * (parent cwd). Anything else must be an absolute path to an existing
@@ -39,6 +46,29 @@ function assertValidSpawnCwd(cwd) {
39
46
  throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
40
47
  }
41
48
  }
49
+ const RESTORABLE_STATUSES = new Set([
50
+ "completed",
51
+ "steered",
52
+ "aborted",
53
+ "stopped",
54
+ "error",
55
+ ]);
56
+ function isRestorableRecord(value) {
57
+ if (!value || typeof value !== "object")
58
+ return false;
59
+ const record = value;
60
+ return (typeof record.id === "string" &&
61
+ record.id.length > 0 &&
62
+ typeof record.type === "string" &&
63
+ typeof record.description === "string" &&
64
+ RESTORABLE_STATUSES.has(record.status) &&
65
+ typeof record.startedAt === "number" &&
66
+ Number.isFinite(record.startedAt) &&
67
+ typeof record.completedAt === "number" &&
68
+ Number.isFinite(record.completedAt) &&
69
+ (record.result === undefined || typeof record.result === "string") &&
70
+ (record.error === undefined || typeof record.error === "string"));
71
+ }
42
72
  class AgentManager {
43
73
  agents = new Map();
44
74
  cleanupInterval;
@@ -425,6 +455,40 @@ class AgentManager {
425
455
  getRecord(id) {
426
456
  return this.agents.get(id);
427
457
  }
458
+ /**
459
+ * Restore terminal records persisted in a parent session.
460
+ *
461
+ * Restored records deliberately have no live session, promise, or abort
462
+ * controller. Invalid data is ignored because session entries are persisted
463
+ * extension data and may have been written by an older version.
464
+ */
465
+ restoreCompleted(records) {
466
+ const restoredIds = new Set();
467
+ // getBranch() is chronological; newest persisted state wins on duplicate IDs.
468
+ for (const value of [...records].reverse()) {
469
+ if (!isRestorableRecord(value))
470
+ continue;
471
+ if (restoredIds.has(value.id) || this.agents.has(value.id))
472
+ continue;
473
+ restoredIds.add(value.id);
474
+ this.agents.set(value.id, {
475
+ id: value.id,
476
+ type: value.type,
477
+ description: value.description,
478
+ status: value.status,
479
+ result: value.result,
480
+ error: value.error,
481
+ toolUses: 0,
482
+ startedAt: value.startedAt,
483
+ completedAt: value.completedAt,
484
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
485
+ compactionCount: 0,
486
+ // Historical records have no inline tool surface and should remain
487
+ // visible in the background widget.
488
+ isBackground: true,
489
+ });
490
+ }
491
+ }
428
492
  listAgents() {
429
493
  return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
430
494
  }
package/dist/index.js CHANGED
@@ -10,6 +10,39 @@
10
10
  * Commands:
11
11
  * /agents — Interactive agent management menu
12
12
  */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
13
46
  Object.defineProperty(exports, "__esModule", { value: true });
14
47
  exports.renderRunningAgentStatus = renderRunningAgentStatus;
15
48
  exports.default = default_1;
@@ -512,6 +545,11 @@ function default_1(pi) {
512
545
  pi.on("session_start", async (_event, ctx) => {
513
546
  currentCtx = ctx;
514
547
  manager.clearCompleted(true);
548
+ const historicalRecords = ctx.sessionManager
549
+ .getBranch()
550
+ .filter((entry) => entry?.type === "custom" && entry.customType === "subagents:record")
551
+ .map((entry) => entry.data);
552
+ manager.restoreCompleted(historicalRecords);
515
553
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
516
554
  // fires once per activation, but a double-bind must not leak listeners.
517
555
  if (!rpcHandle) {
@@ -1611,7 +1649,7 @@ Terse command-style prompts produce shallow, generic work.
1611
1649
  ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1612
1650
  return;
1613
1651
  }
1614
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1652
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await Promise.resolve().then(() => __importStar(require("./ui/conversation-viewer.js")));
1615
1653
  const session = record.session;
1616
1654
  const activity = agentActivity.get(record.id);
1617
1655
  await ctx.ui.custom((tui, theme, keybindings, done) => {
@@ -1660,7 +1698,7 @@ Terse command-style prompts produce shallow, generic work.
1660
1698
  const content = (0, node_fs_1.readFileSync)(file.path, "utf-8");
1661
1699
  const edited = await ctx.ui.editor(`Edit ${name}`, content);
1662
1700
  if (edited !== undefined && edited !== content) {
1663
- const { writeFileSync } = await import("node:fs");
1701
+ const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1664
1702
  writeFileSync(file.path, edited, "utf-8");
1665
1703
  reloadCustomAgents();
1666
1704
  ctx.ui.notify(`Updated ${file.path}`, "info");
@@ -1748,7 +1786,7 @@ Terse command-style prompts produce shallow, generic work.
1748
1786
  if (cfg.isolation)
1749
1787
  fmFields.push(`isolation: ${cfg.isolation}`);
1750
1788
  const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
1751
- const { writeFileSync } = await import("node:fs");
1789
+ const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1752
1790
  writeFileSync(targetPath, content, "utf-8");
1753
1791
  reloadCustomAgents();
1754
1792
  ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
@@ -1764,7 +1802,7 @@ Terse command-style prompts produce shallow, generic work.
1764
1802
  return;
1765
1803
  }
1766
1804
  const updated = content.replace(/^---\n/, "---\nenabled: false\n");
1767
- const { writeFileSync } = await import("node:fs");
1805
+ const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1768
1806
  writeFileSync(file.path, updated, "utf-8");
1769
1807
  reloadCustomAgents();
1770
1808
  ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
@@ -1780,7 +1818,7 @@ Terse command-style prompts produce shallow, generic work.
1780
1818
  const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1781
1819
  (0, node_fs_1.mkdirSync)(targetDir, { recursive: true });
1782
1820
  const targetPath = (0, node_path_1.join)(targetDir, `${name}.md`);
1783
- const { writeFileSync } = await import("node:fs");
1821
+ const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1784
1822
  writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
1785
1823
  reloadCustomAgents();
1786
1824
  ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
@@ -1792,7 +1830,7 @@ Terse command-style prompts produce shallow, generic work.
1792
1830
  return;
1793
1831
  const content = (0, node_fs_1.readFileSync)(file.path, "utf-8");
1794
1832
  const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
1795
- const { writeFileSync } = await import("node:fs");
1833
+ const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1796
1834
  // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
1797
1835
  if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
1798
1836
  (0, node_fs_1.unlinkSync)(file.path);
@@ -1975,7 +2013,7 @@ ${systemPrompt}
1975
2013
  if (!overwrite)
1976
2014
  return;
1977
2015
  }
1978
- const { writeFileSync } = await import("node:fs");
2016
+ const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1979
2017
  writeFileSync(targetPath, content, "utf-8");
1980
2018
  reloadCustomAgents();
1981
2019
  ctx.ui.notify(`Created ${targetPath}`, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
5
5
  "author": "ESSO0428",
6
6
  "repository": {
@@ -23,6 +23,13 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
23
23
 
24
24
  /** Default max concurrent background agents. */
25
25
  const DEFAULT_MAX_CONCURRENT = 4;
26
+ const TERMINAL_STATUSES = new Set<AgentRecord["status"]>([
27
+ "completed",
28
+ "steered",
29
+ "aborted",
30
+ "stopped",
31
+ "error",
32
+ ]);
26
33
 
27
34
  /**
28
35
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
@@ -97,6 +104,37 @@ interface SpawnOptions {
97
104
  onCompaction?: (info: CompactionInfo) => void;
98
105
  }
99
106
 
107
+ const RESTORABLE_STATUSES = new Set<AgentRecord["status"]>([
108
+ "completed",
109
+ "steered",
110
+ "aborted",
111
+ "stopped",
112
+ "error",
113
+ ]);
114
+
115
+ type PersistedAgentRecord = Pick<
116
+ AgentRecord,
117
+ "id" | "type" | "description" | "status" | "result" | "error" | "startedAt" | "completedAt"
118
+ >;
119
+
120
+ function isRestorableRecord(value: unknown): value is PersistedAgentRecord {
121
+ if (!value || typeof value !== "object") return false;
122
+ const record = value as Record<string, unknown>;
123
+ return (
124
+ typeof record.id === "string" &&
125
+ record.id.length > 0 &&
126
+ typeof record.type === "string" &&
127
+ typeof record.description === "string" &&
128
+ RESTORABLE_STATUSES.has(record.status as AgentRecord["status"]) &&
129
+ typeof record.startedAt === "number" &&
130
+ Number.isFinite(record.startedAt) &&
131
+ typeof record.completedAt === "number" &&
132
+ Number.isFinite(record.completedAt) &&
133
+ (record.result === undefined || typeof record.result === "string") &&
134
+ (record.error === undefined || typeof record.error === "string")
135
+ );
136
+ }
137
+
100
138
  export class AgentManager {
101
139
  private agents = new Map<string, AgentRecord>();
102
140
  private cleanupInterval: ReturnType<typeof setInterval>;
@@ -510,6 +548,40 @@ export class AgentManager {
510
548
  return this.agents.get(id);
511
549
  }
512
550
 
551
+ /**
552
+ * Restore terminal records persisted in a parent session.
553
+ *
554
+ * Restored records deliberately have no live session, promise, or abort
555
+ * controller. Invalid data is ignored because session entries are persisted
556
+ * extension data and may have been written by an older version.
557
+ */
558
+ restoreCompleted(records: readonly unknown[]): void {
559
+ const restoredIds = new Set<string>();
560
+ // getBranch() is chronological; newest persisted state wins on duplicate IDs.
561
+ for (const value of [...records].reverse()) {
562
+ if (!isRestorableRecord(value)) continue;
563
+ if (restoredIds.has(value.id) || this.agents.has(value.id)) continue;
564
+ restoredIds.add(value.id);
565
+
566
+ this.agents.set(value.id, {
567
+ id: value.id,
568
+ type: value.type,
569
+ description: value.description,
570
+ status: value.status,
571
+ result: value.result,
572
+ error: value.error,
573
+ toolUses: 0,
574
+ startedAt: value.startedAt,
575
+ completedAt: value.completedAt,
576
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
577
+ compactionCount: 0,
578
+ // Historical records have no inline tool surface and should remain
579
+ // visible in the background widget.
580
+ isBackground: true,
581
+ });
582
+ }
583
+ }
584
+
513
585
  listAgents(): AgentRecord[] {
514
586
  return [...this.agents.values()].sort(
515
587
  (a, b) => b.startedAt - a.startedAt,
package/src/index.ts CHANGED
@@ -571,6 +571,11 @@ export default function (pi: ExtensionAPI) {
571
571
  pi.on("session_start", async (_event, ctx) => {
572
572
  currentCtx = ctx;
573
573
  manager.clearCompleted(true);
574
+ const historicalRecords = ctx.sessionManager
575
+ .getBranch()
576
+ .filter((entry: any) => entry?.type === "custom" && entry.customType === "subagents:record")
577
+ .map((entry: any) => entry.data);
578
+ manager.restoreCompleted(historicalRecords);
574
579
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
575
580
  // fires once per activation, but a double-bind must not leak listeners.
576
581
  if (!rpcHandle) {
@@ -0,0 +1,84 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { AgentManager } from "../src/agent-manager.js";
3
+
4
+ type PersistedRecord = {
5
+ id: string;
6
+ type: string;
7
+ description: string;
8
+ status: string;
9
+ result?: unknown;
10
+ error?: unknown;
11
+ startedAt: number;
12
+ completedAt: number;
13
+ };
14
+
15
+ function persisted(overrides: Partial<PersistedRecord> = {}): PersistedRecord {
16
+ return {
17
+ id: "agent-1",
18
+ type: "general-purpose",
19
+ description: "history test",
20
+ status: "completed",
21
+ result: "restored result",
22
+ startedAt: 100,
23
+ completedAt: 200,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("AgentManager.restoreCompleted", () => {
29
+ let manager: AgentManager | undefined;
30
+
31
+ afterEach(() => {
32
+ manager?.dispose();
33
+ manager = undefined;
34
+ });
35
+
36
+ it("restores terminal records without creating live runtime state", () => {
37
+ manager = new AgentManager();
38
+
39
+ manager.restoreCompleted([
40
+ persisted(),
41
+ persisted({ id: "agent-error", status: "error", error: "failed" }),
42
+ persisted({ id: "agent-running", status: "running" }),
43
+ persisted({ id: "agent-queued", status: "queued" }),
44
+ persisted({ id: "agent-invalid", completedAt: Number.NaN }),
45
+ ]);
46
+
47
+ expect(manager.listAgents().map((record) => record.id).sort()).toEqual([
48
+ "agent-1",
49
+ "agent-error",
50
+ ]);
51
+ const restored = manager.getRecord("agent-1");
52
+ expect(restored).toMatchObject({
53
+ status: "completed",
54
+ result: "restored result",
55
+ toolUses: 0,
56
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
57
+ compactionCount: 0,
58
+ isBackground: true,
59
+ });
60
+ expect(restored?.session).toBeUndefined();
61
+ expect(restored?.promise).toBeUndefined();
62
+ expect(restored?.abortController).toBeUndefined();
63
+ });
64
+
65
+ it("uses the newest branch entry when an id appears more than once", () => {
66
+ manager = new AgentManager();
67
+
68
+ manager.restoreCompleted([
69
+ persisted({ result: "old", completedAt: 200 }),
70
+ persisted({ result: "new", completedAt: 300 }),
71
+ ]);
72
+
73
+ expect(manager.getRecord("agent-1")?.result).toBe("new");
74
+ });
75
+
76
+ it("does not overwrite a record already held by the manager", () => {
77
+ manager = new AgentManager();
78
+
79
+ manager.restoreCompleted([persisted({ result: "first" })]);
80
+ manager.restoreCompleted([persisted({ result: "second" })]);
81
+
82
+ expect(manager.getRecord("agent-1")?.result).toBe("first");
83
+ });
84
+ });