@esso0428/pi-subagents 0.15.5 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +4 -22
  2. package/README.md +5 -5
  3. package/dist/agent-history-list.d.ts +18 -0
  4. package/dist/agent-history-list.js +68 -0
  5. package/dist/agent-history.d.ts +1 -1
  6. package/dist/agent-history.js +31 -34
  7. package/dist/agent-manager.d.ts +22 -8
  8. package/dist/agent-manager.js +100 -93
  9. package/dist/agent-runner.js +67 -86
  10. package/dist/agent-types.js +27 -45
  11. package/dist/context.js +2 -6
  12. package/dist/cross-extension-rpc.js +5 -9
  13. package/dist/custom-agents.js +15 -18
  14. package/dist/default-agents.js +1 -4
  15. package/dist/enabled-models.js +11 -16
  16. package/dist/env.js +1 -4
  17. package/dist/group-join.js +1 -5
  18. package/dist/index.js +273 -286
  19. package/dist/invocation-config.js +2 -6
  20. package/dist/memory.js +24 -34
  21. package/dist/model-resolver.js +1 -4
  22. package/dist/nico-overrides.js +14 -20
  23. package/dist/output-file.js +16 -22
  24. package/dist/prompts.js +1 -4
  25. package/dist/schedule-store.js +16 -21
  26. package/dist/schedule.js +8 -12
  27. package/dist/settings.js +15 -23
  28. package/dist/skill-loader.js +20 -23
  29. package/dist/status-note.js +1 -4
  30. package/dist/types.d.ts +0 -1
  31. package/dist/types.js +1 -4
  32. package/dist/ui/agent-widget.d.ts +1 -5
  33. package/dist/ui/agent-widget.js +25 -49
  34. package/dist/ui/conversation-blocks.d.ts +18 -0
  35. package/dist/ui/conversation-blocks.js +214 -0
  36. package/dist/ui/conversation-nvim.d.ts +7 -0
  37. package/dist/ui/conversation-nvim.js +116 -0
  38. package/dist/ui/conversation-search.d.ts +38 -0
  39. package/dist/ui/conversation-search.js +123 -0
  40. package/dist/ui/conversation-viewer.d.ts +55 -19
  41. package/dist/ui/conversation-viewer.js +398 -215
  42. package/dist/ui/fleet-list.d.ts +23 -11
  43. package/dist/ui/fleet-list.js +85 -78
  44. package/dist/ui/schedule-menu.js +1 -4
  45. package/dist/ui/viewer-keys.js +7 -10
  46. package/dist/usage.js +4 -10
  47. package/dist/worktree.js +26 -31
  48. package/package.json +1 -1
  49. package/src/agent-history-list.ts +91 -0
  50. package/src/agent-history.ts +12 -17
  51. package/src/agent-manager.ts +108 -81
  52. package/src/agent-runner.ts +5 -13
  53. package/src/index.ts +84 -89
  54. package/src/output-file.ts +1 -1
  55. package/src/types.ts +0 -1
  56. package/src/ui/agent-widget.ts +1 -8
  57. package/src/ui/conversation-blocks.ts +270 -0
  58. package/src/ui/conversation-nvim.ts +127 -0
  59. package/src/ui/conversation-search.ts +161 -0
  60. package/src/ui/conversation-viewer.ts +376 -205
  61. package/src/ui/fleet-list.ts +73 -52
  62. package/test/agent-history-list.test.ts +121 -0
  63. package/test/agent-manager-history.test.ts +59 -58
  64. package/test/agent-menu-history-navigation.test.ts +86 -0
  65. package/test/ui/conversation-blocks.test.ts +72 -0
  66. package/test/ui/conversation-search.test.ts +55 -0
  67. package/test/ui/conversation-viewer.test.ts +132 -0
  68. package/test/ui/fleet-list.test.ts +131 -0
  69. package/dist/ui/markdown-result.d.ts +0 -3
  70. package/dist/ui/markdown-result.js +0 -53
  71. package/src/ui/markdown-result.ts +0 -56
  72. package/test/agent-history.test.ts +0 -63
  73. package/test/ui/markdown-result.test.ts +0 -45
package/dist/worktree.js CHANGED
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /**
3
2
  * worktree.ts — Git worktree isolation for agents.
4
3
  *
@@ -6,51 +5,47 @@
6
5
  * On completion, if no changes were made, the worktree is cleaned up.
7
6
  * If changes exist, a branch is created and returned in the result.
8
7
  */
9
- Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.createWorktree = createWorktree;
11
- exports.cleanupWorktree = cleanupWorktree;
12
- exports.pruneWorktrees = pruneWorktrees;
13
- const node_child_process_1 = require("node:child_process");
14
- const node_crypto_1 = require("node:crypto");
15
- const node_fs_1 = require("node:fs");
16
- const node_os_1 = require("node:os");
17
- const node_path_1 = require("node:path");
8
+ import { execFileSync } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import { existsSync, realpathSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, relative } from "node:path";
18
13
  /**
19
14
  * Create a temporary git worktree for an agent.
20
15
  * Returns the worktree path, or undefined if not in a git repo.
21
16
  */
22
- function createWorktree(cwd, agentId) {
17
+ export function createWorktree(cwd, agentId) {
23
18
  // Verify we're in a git repo with at least one commit (HEAD must exist)
24
19
  let baseSha;
25
20
  let subdir;
26
21
  try {
27
- (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
28
- baseSha = (0, node_child_process_1.execFileSync)("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
22
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
23
+ baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
29
24
  .toString()
30
25
  .trim();
31
26
  // Where cwd sits inside the repo ("" at the root): the agent must work at
32
27
  // the same subdirectory inside the copy, or a monorepo-package cwd would
33
28
  // silently widen to the whole repo. realpath both sides — git emits
34
29
  // resolved paths while cwd may arrive through a symlink (macOS /tmp).
35
- const topLevel = (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
30
+ const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
36
31
  .toString()
37
32
  .trim();
38
- subdir = (0, node_path_1.relative)((0, node_fs_1.realpathSync)(topLevel), (0, node_fs_1.realpathSync)(cwd));
33
+ subdir = relative(realpathSync(topLevel), realpathSync(cwd));
39
34
  }
40
35
  catch {
41
36
  return undefined;
42
37
  }
43
38
  const branch = `pi-agent-${agentId}`;
44
- const suffix = (0, node_crypto_1.randomUUID)().slice(0, 8);
45
- const worktreePath = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `pi-agent-${agentId}-${suffix}`);
39
+ const suffix = randomUUID().slice(0, 8);
40
+ const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
46
41
  try {
47
42
  // Create detached worktree at HEAD
48
- (0, node_child_process_1.execFileSync)("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
43
+ execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
49
44
  cwd,
50
45
  stdio: "pipe",
51
46
  timeout: 30000,
52
47
  });
53
- return { path: worktreePath, branch, baseSha, workPath: subdir ? (0, node_path_1.join)(worktreePath, subdir) : worktreePath };
48
+ return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
54
49
  }
55
50
  catch {
56
51
  // If worktree creation fails, return undefined (agent runs in normal cwd)
@@ -62,31 +57,31 @@ function createWorktree(cwd, agentId) {
62
57
  * - If no changes: remove worktree entirely.
63
58
  * - If changes exist: create a branch, commit changes, return branch info.
64
59
  */
65
- function cleanupWorktree(cwd, worktree, agentDescription) {
66
- if (!(0, node_fs_1.existsSync)(worktree.path)) {
60
+ export function cleanupWorktree(cwd, worktree, agentDescription) {
61
+ if (!existsSync(worktree.path)) {
67
62
  return { hasChanges: false };
68
63
  }
69
64
  try {
70
65
  // Check for uncommitted changes in the worktree
71
- const status = (0, node_child_process_1.execFileSync)("git", ["status", "--porcelain"], {
66
+ const status = execFileSync("git", ["status", "--porcelain"], {
72
67
  cwd: worktree.path,
73
68
  stdio: "pipe",
74
69
  timeout: 10000,
75
70
  }).toString().trim();
76
71
  if (status) {
77
72
  // Changes exist — stage, commit, and create a branch
78
- (0, node_child_process_1.execFileSync)("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
73
+ execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
79
74
  // Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
80
75
  const safeDesc = agentDescription.slice(0, 200);
81
76
  const commitMsg = `pi-agent: ${safeDesc}`;
82
- (0, node_child_process_1.execFileSync)("git", ["commit", "--no-verify", "-m", commitMsg], {
77
+ execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
83
78
  cwd: worktree.path,
84
79
  stdio: "pipe",
85
80
  timeout: 10000,
86
81
  });
87
82
  }
88
83
  else {
89
- const currentSha = (0, node_child_process_1.execFileSync)("git", ["rev-parse", "HEAD"], {
84
+ const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
90
85
  cwd: worktree.path,
91
86
  stdio: "pipe",
92
87
  timeout: 5000,
@@ -101,7 +96,7 @@ function cleanupWorktree(cwd, worktree, agentDescription) {
101
96
  // If the branch already exists, append a suffix to avoid overwriting previous work.
102
97
  let branchName = worktree.branch;
103
98
  try {
104
- (0, node_child_process_1.execFileSync)("git", ["branch", branchName], {
99
+ execFileSync("git", ["branch", branchName], {
105
100
  cwd: worktree.path,
106
101
  stdio: "pipe",
107
102
  timeout: 5000,
@@ -110,7 +105,7 @@ function cleanupWorktree(cwd, worktree, agentDescription) {
110
105
  catch {
111
106
  // Branch already exists — use a unique suffix
112
107
  branchName = `${worktree.branch}-${Date.now()}`;
113
- (0, node_child_process_1.execFileSync)("git", ["branch", branchName], {
108
+ execFileSync("git", ["branch", branchName], {
114
109
  cwd: worktree.path,
115
110
  stdio: "pipe",
116
111
  timeout: 5000,
@@ -140,7 +135,7 @@ function cleanupWorktree(cwd, worktree, agentDescription) {
140
135
  */
141
136
  function removeWorktree(cwd, worktreePath) {
142
137
  try {
143
- (0, node_child_process_1.execFileSync)("git", ["worktree", "remove", "--force", worktreePath], {
138
+ execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
144
139
  cwd,
145
140
  stdio: "pipe",
146
141
  timeout: 10000,
@@ -149,7 +144,7 @@ function removeWorktree(cwd, worktreePath) {
149
144
  catch {
150
145
  // If git worktree remove fails, try pruning
151
146
  try {
152
- (0, node_child_process_1.execFileSync)("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
147
+ execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
153
148
  }
154
149
  catch { /* ignore */ }
155
150
  }
@@ -157,9 +152,9 @@ function removeWorktree(cwd, worktreePath) {
157
152
  /**
158
153
  * Prune any orphaned worktrees (crash recovery).
159
154
  */
160
- function pruneWorktrees(cwd) {
155
+ export function pruneWorktrees(cwd) {
161
156
  try {
162
- (0, node_child_process_1.execFileSync)("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
157
+ execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
163
158
  }
164
159
  catch { /* ignore */ }
165
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.15.5",
3
+ "version": "0.15.6",
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": {
@@ -0,0 +1,91 @@
1
+ /** Helpers for separating openable active agents from terminal history. */
2
+
3
+ import { hasAgentHistory } from "./agent-history.js";
4
+ import type { AgentRecord } from "./types.js";
5
+ import { getAgentConfig } from "./agent-types.js";
6
+ import { formatDuration, getDisplayName } from "./ui/agent-widget.js";
7
+
8
+ export type AgentHistoryStatus =
9
+ | "completed"
10
+ | "steered"
11
+ | "stopped"
12
+ | "aborted"
13
+ | "error";
14
+
15
+ const TERMINAL_STATUSES: ReadonlySet<string> = new Set<AgentHistoryStatus>([
16
+ "completed",
17
+ "steered",
18
+ "stopped",
19
+ "aborted",
20
+ "error",
21
+ ]);
22
+
23
+ /** Return whether a record status represents a terminal agent run. */
24
+ export function isTerminalAgentStatus(status: string): status is AgentHistoryStatus {
25
+ return TERMINAL_STATUSES.has(status);
26
+ }
27
+
28
+ /** Return whether an active record has a live session that can be opened. */
29
+ export function canOpenActiveAgent(record: AgentRecord): boolean {
30
+ return (record.status === "running" || record.status === "queued")
31
+ && record.session !== undefined;
32
+ }
33
+
34
+ /** Return whether a terminal record has an in-memory or durable conversation. */
35
+ export function canOpenAgentHistory(record: AgentRecord, cwd: string | undefined): boolean {
36
+ if (!isTerminalAgentStatus(record.status)) return false;
37
+ return record.session !== undefined
38
+ || (cwd !== undefined && hasAgentHistory(cwd, record.transcriptPath));
39
+ }
40
+
41
+ /** Split records into openable active and terminal-history buckets. */
42
+ export function splitAgentRecords(
43
+ records: readonly AgentRecord[],
44
+ cwd: string | undefined,
45
+ ): { active: AgentRecord[]; history: AgentRecord[] } {
46
+ const active: AgentRecord[] = [];
47
+ const history: AgentRecord[] = [];
48
+
49
+ for (const record of records) {
50
+ if (canOpenActiveAgent(record)) {
51
+ active.push(record);
52
+ } else if (canOpenAgentHistory(record, cwd)) {
53
+ history.push(record);
54
+ }
55
+ }
56
+
57
+ return { active, history };
58
+ }
59
+
60
+ const DESCRIPTION_LIMIT = 72;
61
+ const LABEL_LIMIT = 140;
62
+
63
+ function boundedDescription(description: string): string {
64
+ const line = description.split("\n").find((part) => part.trim())?.trim() ?? "";
65
+ if (line.length <= DESCRIPTION_LIMIT) return line;
66
+ return `${line.slice(0, DESCRIPTION_LIMIT - 1)}…`;
67
+ }
68
+
69
+ /** Format a bounded, single-line history menu option. */
70
+ export function formatAgentHistoryOption(record: AgentRecord, now: number): string {
71
+ const duration = formatDuration(record.startedAt, record.completedAt ?? now);
72
+ // Unit consumers may format persisted records before the registry has been
73
+ // initialized; preserve the persisted type instead of falling back to
74
+ // general-purpose's generic display name in that case.
75
+ const displayName = getAgentConfig(record.type)?.displayName
76
+ ?? (record.type === "general-purpose" ? getDisplayName(record.type) : record.type);
77
+ const option = `[${record.status}] ${displayName} · ${boundedDescription(record.description)} · ${duration}`;
78
+ return option.length <= LABEL_LIMIT ? option : `${option.slice(0, LABEL_LIMIT - 1)}…`;
79
+ }
80
+
81
+ /** Build the visible status-section labels for the agents menu. */
82
+ export function buildAgentStatusMenuEntries(
83
+ records: readonly AgentRecord[],
84
+ cwd: string | undefined,
85
+ ): string[] {
86
+ const { active, history } = splitAgentRecords(records, cwd);
87
+ return [
88
+ ...(active.length > 0 ? [`Running agents (${active.length})`] : []),
89
+ ...(history.length > 0 ? [`Agent history (${history.length})`] : []),
90
+ ];
91
+ }
@@ -19,14 +19,12 @@ export function ensureSubagentsGitignore(cwd: string): string {
19
19
  const directory = join(cwd, SUBAGENTS_DIR);
20
20
  mkdirSync(directory, { recursive: true });
21
21
  const path = join(directory, ".gitignore");
22
-
23
22
  let content = "";
24
23
  try {
25
24
  content = readFileSync(path, "utf8");
26
25
  } catch {
27
26
  // The append below also creates a missing file.
28
27
  }
29
-
30
28
  if (!content.split(/\r?\n/).some((line) => line.trim() === "*")) {
31
29
  const separator = content.length > 0 && !/[\r\n]$/.test(content) ? "\n" : "";
32
30
  appendFileSync(path, `${separator}*\n`, "utf8");
@@ -35,17 +33,12 @@ export function ensureSubagentsGitignore(cwd: string): string {
35
33
  }
36
34
 
37
35
  /** Return the durable transcript path for an agent. */
38
- export function createAgentHistoryPath(
39
- cwd: string,
40
- agentId: string,
41
- agentType = "agent",
42
- ): string {
36
+ export function createAgentHistoryPath(cwd: string, agentId: string): string {
43
37
  ensureSubagentsGitignore(cwd);
44
38
  const directory = join(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
45
39
  mkdirSync(directory, { recursive: true });
46
40
  const safeId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
47
- const safeType = agentType.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
48
- return join(directory, `${safeId}_${safeType}_transcript.jsonl`);
41
+ return join(directory, `${safeId}.jsonl`);
49
42
  }
50
43
 
51
44
  /** Return the project-relative path stored in the parent session record. */
@@ -55,7 +48,12 @@ export function agentHistoryLocator(cwd: string, historyPath: string): string {
55
48
 
56
49
  /** Resolve only paths in this package's project-local transcript namespace. */
57
50
  export function resolveAgentHistoryPath(cwd: string, locator: string): string | undefined {
58
- if (!locator || isAbsolute(locator)) return undefined;
51
+ // Locators are persisted data, not arbitrary paths. Reject traversal syntax
52
+ // before resolving so a path which normalizes back inside the namespace is
53
+ // still not accepted as an unsafe locator.
54
+ if (!locator || isAbsolute(locator) || locator.includes("\0") || locator.includes("\\")) return undefined;
55
+ const segments = locator.split("/");
56
+ if (segments.some((segment) => segment === ".." || segment === "")) return undefined;
59
57
  const root = resolve(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
60
58
  const candidate = resolve(cwd, locator);
61
59
  if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) return undefined;
@@ -74,12 +72,9 @@ export function hasAgentHistory(cwd: string, locator: string | undefined): boole
74
72
  * conversation viewer. Malformed lines and unknown records are skipped so one
75
73
  * damaged entry cannot hide the rest of a history.
76
74
  */
77
- export function readAgentHistory(
78
- cwd: string,
79
- locator: string,
80
- ): AgentSession["messages"] | undefined {
75
+ export function readAgentHistory(cwd: string, locator: string): AgentSession["messages"] | undefined {
81
76
  const path = resolveAgentHistoryPath(cwd, locator);
82
- if (!path) return undefined;
77
+ if (!path || !existsSync(path)) return undefined;
83
78
 
84
79
  let raw: string;
85
80
  try {
@@ -93,8 +88,8 @@ export function readAgentHistory(
93
88
  for (const line of raw.split(/\r?\n/)) {
94
89
  if (!line.trim()) continue;
95
90
  try {
96
- const entry = JSON.parse(line) as { message?: unknown };
97
- const message = entry.message;
91
+ const entry: unknown = JSON.parse(line);
92
+ const message = (entry as { message?: unknown } | null)?.message;
98
93
  if (!message || typeof message !== "object") continue;
99
94
  const role = (message as { role?: unknown }).role;
100
95
  if (typeof role !== "string") continue;
@@ -23,13 +23,67 @@ 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
+ const RESTORABLE_STATUSES = new Set(["completed", "steered", "stopped", "aborted", "error"] as const);
27
+
28
+ type RestorableAgentStatus = "completed" | "steered" | "stopped" | "aborted" | "error";
29
+
30
+ /** Narrow persisted strings before putting them into runtime/UI state. */
31
+ function isSafePersistedString(value: unknown, maxLength: number): value is string {
32
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength && !/[\0\r\n]/.test(value);
33
+ }
34
+
35
+ function isFiniteTimestamp(value: unknown): value is number {
36
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
37
+ }
38
+
39
+ function isValidUsage(value: unknown): value is { input: number; output: number; cacheWrite: number } {
40
+ if (!value || typeof value !== "object") return false;
41
+ const usage = value as Record<string, unknown>;
42
+ return ["input", "output", "cacheWrite"].every((key) => {
43
+ const n = usage[key];
44
+ return typeof n === "number" && Number.isFinite(n) && n >= 0;
45
+ });
46
+ }
47
+
48
+ function isSafeTranscriptLocator(value: unknown): value is string {
49
+ return typeof value === "string"
50
+ && /^\.pi-subagents\/agent-transcripts\/[^/]+\.jsonl$/.test(value)
51
+ && !value.includes("..")
52
+ && !value.includes("\\")
53
+ && !value.includes("\0");
54
+ }
55
+
56
+ /** Validate one persisted terminal record without constructing runtime handles. */
57
+ export function isRestorableAgentRecord(value: unknown): value is {
58
+ id: string;
59
+ type: string;
60
+ description: string;
61
+ status: RestorableAgentStatus;
62
+ startedAt: number;
63
+ completedAt: number;
64
+ result?: string;
65
+ error?: string;
66
+ toolUses?: number;
67
+ lifetimeUsage?: { input: number; output: number; cacheWrite: number };
68
+ transcriptPath?: string;
69
+ } {
70
+ if (!value || typeof value !== "object") return false;
71
+ const record = value as Record<string, unknown>;
72
+ if (!isSafePersistedString(record.id, 256)
73
+ || !isSafePersistedString(record.type, 256)
74
+ || !isSafePersistedString(record.description, 4096)
75
+ || typeof record.status !== "string"
76
+ || !RESTORABLE_STATUSES.has(record.status as RestorableAgentStatus)
77
+ || !isFiniteTimestamp(record.startedAt)
78
+ || !isFiniteTimestamp(record.completedAt)
79
+ || record.completedAt < record.startedAt) return false;
80
+ if (record.result !== undefined && !isSafePersistedString(record.result, 2_000_000)) return false;
81
+ if (record.error !== undefined && !isSafePersistedString(record.error, 64_000)) return false;
82
+ if (record.toolUses !== undefined && (!Number.isInteger(record.toolUses) || (record.toolUses as number) < 0)) return false;
83
+ if (record.lifetimeUsage !== undefined && !isValidUsage(record.lifetimeUsage)) return false;
84
+ if (record.transcriptPath !== undefined && !isSafeTranscriptLocator(record.transcriptPath)) return false;
85
+ return true;
86
+ }
33
87
 
34
88
  /**
35
89
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
@@ -104,45 +158,6 @@ interface SpawnOptions {
104
158
  onCompaction?: (info: CompactionInfo) => void;
105
159
  }
106
160
 
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" | "transcriptPath"
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
- const transcriptPath = record.transcriptPath;
124
- const validTranscriptPath =
125
- transcriptPath === undefined ||
126
- (typeof transcriptPath === "string" &&
127
- transcriptPath.startsWith(".pi-subagents/agent-transcripts/") &&
128
- !transcriptPath.includes("..") &&
129
- !transcriptPath.includes("\\"));
130
- return (
131
- typeof record.id === "string" &&
132
- record.id.length > 0 &&
133
- typeof record.type === "string" &&
134
- typeof record.description === "string" &&
135
- RESTORABLE_STATUSES.has(record.status as AgentRecord["status"]) &&
136
- typeof record.startedAt === "number" &&
137
- Number.isFinite(record.startedAt) &&
138
- typeof record.completedAt === "number" &&
139
- Number.isFinite(record.completedAt) &&
140
- (record.result === undefined || typeof record.result === "string") &&
141
- (record.error === undefined || typeof record.error === "string") &&
142
- validTranscriptPath
143
- );
144
- }
145
-
146
161
  export class AgentManager {
147
162
  private agents = new Map<string, AgentRecord>();
148
163
  private cleanupInterval: ReturnType<typeof setInterval>;
@@ -556,47 +571,59 @@ export class AgentManager {
556
571
  return this.agents.get(id);
557
572
  }
558
573
 
559
- /**
560
- * Restore terminal records persisted in a parent session.
561
- *
562
- * Restored records deliberately have no live session, promise, or abort
563
- * controller. Invalid data is ignored because session entries are persisted
564
- * extension data and may have been written by an older version.
565
- */
566
- restoreCompleted(records: readonly unknown[]): void {
567
- const restoredIds = new Set<string>();
568
- // getBranch() is chronological; newest persisted state wins on duplicate IDs.
569
- for (const value of [...records].reverse()) {
570
- if (!isRestorableRecord(value)) continue;
571
- if (restoredIds.has(value.id) || this.agents.has(value.id)) continue;
572
- restoredIds.add(value.id);
573
-
574
- this.agents.set(value.id, {
575
- id: value.id,
576
- type: value.type,
577
- description: value.description,
578
- status: value.status,
579
- result: value.result,
580
- error: value.error,
581
- transcriptPath: value.transcriptPath,
582
- toolUses: 0,
583
- startedAt: value.startedAt,
584
- completedAt: value.completedAt,
585
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
586
- compactionCount: 0,
587
- // Historical records have no inline tool surface and should remain
588
- // visible in the background widget.
589
- isBackground: true,
590
- });
591
- }
592
- }
593
-
594
574
  listAgents(): AgentRecord[] {
595
575
  return [...this.agents.values()].sort(
596
576
  (a, b) => b.startedAt - a.startedAt,
597
577
  );
598
578
  }
599
579
 
580
+ /** Restore terminal records persisted by a parent branch without runtime handles. */
581
+ restoreCompleted(records: readonly unknown[]): void {
582
+ const latest = new Map<string, ReturnType<typeof this.createRestoredRecord>>();
583
+ for (const value of records) {
584
+ if (isRestorableAgentRecord(value)) {
585
+ latest.set(value.id, this.createRestoredRecord(value));
586
+ }
587
+ }
588
+
589
+ for (const [id, restored] of latest) {
590
+ const existing = this.agents.get(id);
591
+ if (existing?.status === "running" || existing?.status === "queued") continue;
592
+ this.agents.set(id, restored);
593
+ }
594
+ }
595
+
596
+ private createRestoredRecord(record: {
597
+ id: string;
598
+ type: string;
599
+ description: string;
600
+ status: RestorableAgentStatus;
601
+ startedAt: number;
602
+ completedAt: number;
603
+ result?: string;
604
+ error?: string;
605
+ toolUses?: number;
606
+ lifetimeUsage?: { input: number; output: number; cacheWrite: number };
607
+ transcriptPath?: string;
608
+ }): AgentRecord {
609
+ return {
610
+ id: record.id,
611
+ type: record.type,
612
+ description: record.description,
613
+ status: record.status,
614
+ result: record.result,
615
+ error: record.error,
616
+ toolUses: record.toolUses ?? 0,
617
+ startedAt: record.startedAt,
618
+ completedAt: record.completedAt,
619
+ transcriptPath: record.transcriptPath,
620
+ lifetimeUsage: record.lifetimeUsage
621
+ ? { ...record.lifetimeUsage }
622
+ : { input: 0, output: 0, cacheWrite: 0 },
623
+ compactionCount: 0,
624
+ };
625
+ }
626
+
600
627
  abort(id: string): boolean {
601
628
  const record = this.agents.get(id);
602
629
  if (!record) return false;
@@ -6,7 +6,7 @@ import { readFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
8
8
  import type { Model } from "@earendil-works/pi-ai";
9
- import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionContext, LoadExtensionsResult, ModelRuntime } from "@earendil-works/pi-coding-agent";
10
10
  import {
11
11
  type AgentSession,
12
12
  type AgentSessionEvent,
@@ -785,25 +785,17 @@ export async function runAgent(
785
785
  // Pi 0.80.8 replaced createAgentSession's modelRegistry option with
786
786
  // modelRuntime, but ExtensionContext still exposes only the registry facade.
787
787
  // Pass both so the full supported Pi range retains the parent's providers.
788
- const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
789
- type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
790
- // `modelRuntime` was added in Pi 0.80.8. Infer it when available, while
791
- // falling back to unknown for older Pi declarations.
792
- type CompatibleModelRuntime = CreateAgentSessionOptions extends {
793
- modelRuntime?: infer Runtime;
794
- } ? Runtime : unknown;
795
- const sessionOpts: CreateAgentSessionOptions & {
788
+ const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: ModelRuntime | null }).runtime ?? undefined;
789
+ const sessionOpts: Parameters<typeof createAgentSession>[0] & {
796
790
  modelRegistry: ExtensionContext["modelRegistry"];
797
- modelRuntime?: CompatibleModelRuntime;
791
+ modelRuntime?: ModelRuntime;
798
792
  } = {
799
793
  cwd: effectiveCwd,
800
794
  agentDir,
801
795
  sessionManager,
802
796
  settingsManager,
803
797
  modelRegistry: ctx.modelRegistry,
804
- ...(parentModelRuntime != null && {
805
- modelRuntime: parentModelRuntime as CompatibleModelRuntime,
806
- }),
798
+ ...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime }),
807
799
  model,
808
800
  tools: sessionTools,
809
801
  resourceLoader: loader,