@esso0428/pi-subagents 0.15.4 → 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 -17
  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 -284
  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 -85
  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/CHANGELOG.md CHANGED
@@ -7,25 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.15.4] - 2026-09-11
10
+ ## [0.15.6] - 2026-09-11
11
11
 
12
12
  ### Fixed
13
- - **Unavailable history records no longer load the subagent panels.** Reload-restored metadata is shown in AgentWidget, FleetView, and `/agents` only when its project-local transcript still exists; stale records remain available through the normal session result/history data without creating a dead-end Enter target.
14
-
15
- ## [0.15.3] - 2026-09-11
16
-
17
- ### Added
18
- - **Reload-safe transcript history.** Agents now keep a project-local copy under `.pi-subagents/agent-transcripts/`; terminal records persist a relative locator and `/agents`/FleetView can open a read-only history viewer after reload. The shared `.pi-subagents/.gitignore` is created with `*` to keep private transcripts out of git.
19
-
20
- ## [0.15.2] - 2026-09-10
21
-
22
- ### Added
23
- - **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.
24
-
25
- ## [0.15.1] - 2026-09-10
26
-
27
- ### Added
28
- - **Markdown-rendered subagent results.** Expanded foreground results and background completion notifications now render headings, lists, inline code, and fenced code through pi's Markdown renderer. Display output is capped to keep terminal notifications bounded; use `get_subagent_result` for the complete result.
13
+ - **FleetView stays within a six-row budget** while preserving full-roster keyboard navigation, selected-row visibility, overflow indicators, and prompt-area separation.
14
+ - **Agent history and live FleetView are routed separately**, keeping terminal records in the read-only history viewer instead of treating them as active sessions.
15
+ - **Pi 0.85.1 model-runtime type compatibility** is restored without changing the legacy fallback path.
29
16
 
30
17
  ## [0.14.3] - 2026-07-23
31
18
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
28
28
  - **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
29
29
  - **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
30
30
  - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
31
- - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expanded results render headings, lists, inline code, and fenced code as Markdown, with a bounded display to keep the TUI responsive. Group completions render each agent individually
31
+ - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
32
32
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
33
33
  - **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on session start
34
34
  - **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same `subagent-notification` followUp path as manual background completions; manage via `/agents → Scheduled jobs`
@@ -138,9 +138,9 @@ Individual agent results render Claude Code-style in the conversation:
138
138
  | **Error** | `✗ ↻3 · 3 tool uses · 12.4k token (8%)` / `⎿ Error: timeout` |
139
139
  | **Aborted** | `✗ ↻55≤50 · 55 tool uses · 102.3k token (95% · ⇊3)` / `⎿ Aborted (max turns exceeded)` |
140
140
 
141
- Completed results can be expanded (ctrl+o in pi) to show the agent output inline with Markdown formatting. Expanded output is capped at 50 rendered lines (background notification results at 30 lines) and ends with a marker when more output is available; use `get_subagent_result` for the complete result.
141
+ Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
142
142
 
143
- By default, foreground and background agents stream their full conversation to two per-subagent transcripts: the existing temporary JSON-lines file at `<os-tmpdir>/pi-subagents-<uid>/<cwd>/<session>/tasks/<agent-id>.output` (owner-only `0700`, cleared on reboot), plus a durable copy at `<cwd>/.pi-subagents/agent-transcripts/<agent-id>_<agent-type>_transcript.jsonl`. The project-local `.pi-subagents/.gitignore` contains `*` so these private transcripts are not accidentally uploaded to git. After `/reload`, terminal records use the durable locator to open a read-only history viewer. Set `output_transcript: false` on a custom agent, or `outputTranscript: false` in `subagents.json`, to suppress both transcript files. This governs **only** the transcript: it is independent of `persist_session` (the pi session on disk), and it does not affect `isolation: worktree` or `memory:`. Background agent completion notifications render as styled boxes:
143
+ By default, foreground and background agents each stream their full conversation to a per-subagent transcript — a JSON-lines file at `<os-tmpdir>/pi-subagents-<uid>/<cwd>/<session>/tasks/<agent-id>.output` (owner-only `0700`, cleared on reboot). Set `output_transcript: false` on a custom agent to write no transcript path or file for it, or set `outputTranscript: false` in `subagents.json` to make transcripts opt-in for the whole project (frontmatter overrides the project default). This governs **only** the transcript: it is independent of `persist_session` (the pi session on disk), and it does not affect `isolation: worktree` (which commits the agent's work to a git branch) or `memory:` (durable files) — set those accordingly if the goal is to keep a run off disk entirely. Background agent completion notifications render as styled boxes:
144
144
 
145
145
  ```
146
146
  ✓ Find auth files completed
@@ -222,7 +222,7 @@ All fields are optional — sensible defaults for everything.
222
222
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down |
223
223
  | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
224
224
  | `persist_session` | `false` | Persist this subagent as a normal pi session instead of keeping the session in memory only. The subagent's `.output` transcript is still written either way unless `output_transcript: false` |
225
- | `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write the temporary `.output` transcript and durable `.pi-subagents/agent-transcripts/` copy; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write neither. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` |
225
+ | `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write this subagent's `.output` transcript; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write no transcript file or path. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` |
226
226
  | `session_dir` | pi default | Optional session directory when `persist_session: true`; omitted uses pi's normal session location, and relative paths resolve from the agent cwd |
227
227
  | `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) |
228
228
  | `inherit_context` | `false` | Fork parent conversation into agent |
@@ -508,7 +508,7 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
508
508
 
509
509
  **Disable defaults** (`disableDefaultAgents`, default `false`): when on, the three built-in agents (general-purpose, Explore, Plan) are not registered — only your project/global custom agents are advertised and spawnable. User-defined agents are unaffected, including ones that override a default by name. The Agent tool's type list updates on the next pi session (the tool schema is registered at startup).
510
510
 
511
- **Output transcript** (`outputTranscript`, default `true`): the project/global default for writing each subagent's temporary `.output` transcript and durable project-local history copy under `.pi-subagents/agent-transcripts/`. Toggle via `/agents → Settings → Output transcript`, or set `false` in `subagents.json` to make both transcripts opt-in project-wide. A custom agent's `output_transcript` frontmatter overrides this per agent. Applied live at spawn time. Governs only the transcript, not `persist_session`, worktree commits, or memory files.
511
+ **Output transcript** (`outputTranscript`, default `true`): the project/global default for writing each subagent's `.output` transcript. Toggle via `/agents → Settings → Output transcript`, or set `false` in `subagents.json` to make transcripts opt-in project-wide — useful when run transcripts shouldn't sit on disk for backup or DLP tooling to pick up. A custom agent's `output_transcript` frontmatter overrides this per agent. Applied live at spawn time. Governs only the transcript, not `persist_session`, worktree commits, or memory files.
512
512
 
513
513
  **Tool description** (`toolDescriptionMode`, default `"full"`): which Agent tool description the LLM sees. `"full"` is the rich Claude Code-style prompt (~1,400 tokens with the default agents); `"compact"` is ~75% smaller — one-line agent type list, terse usage notes — for small/local models where tool-spec tokens are expensive. Per-option details stay in the parameter descriptions in every mode (the parameter schema is never customizable). Applies on the next pi session.
514
514
 
@@ -0,0 +1,18 @@
1
+ /** Helpers for separating openable active agents from terminal history. */
2
+ import type { AgentRecord } from "./types.js";
3
+ export type AgentHistoryStatus = "completed" | "steered" | "stopped" | "aborted" | "error";
4
+ /** Return whether a record status represents a terminal agent run. */
5
+ export declare function isTerminalAgentStatus(status: string): status is AgentHistoryStatus;
6
+ /** Return whether an active record has a live session that can be opened. */
7
+ export declare function canOpenActiveAgent(record: AgentRecord): boolean;
8
+ /** Return whether a terminal record has an in-memory or durable conversation. */
9
+ export declare function canOpenAgentHistory(record: AgentRecord, cwd: string | undefined): boolean;
10
+ /** Split records into openable active and terminal-history buckets. */
11
+ export declare function splitAgentRecords(records: readonly AgentRecord[], cwd: string | undefined): {
12
+ active: AgentRecord[];
13
+ history: AgentRecord[];
14
+ };
15
+ /** Format a bounded, single-line history menu option. */
16
+ export declare function formatAgentHistoryOption(record: AgentRecord, now: number): string;
17
+ /** Build the visible status-section labels for the agents menu. */
18
+ export declare function buildAgentStatusMenuEntries(records: readonly AgentRecord[], cwd: string | undefined): string[];
@@ -0,0 +1,68 @@
1
+ /** Helpers for separating openable active agents from terminal history. */
2
+ import { hasAgentHistory } from "./agent-history.js";
3
+ import { getAgentConfig } from "./agent-types.js";
4
+ import { formatDuration, getDisplayName } from "./ui/agent-widget.js";
5
+ const TERMINAL_STATUSES = new Set([
6
+ "completed",
7
+ "steered",
8
+ "stopped",
9
+ "aborted",
10
+ "error",
11
+ ]);
12
+ /** Return whether a record status represents a terminal agent run. */
13
+ export function isTerminalAgentStatus(status) {
14
+ return TERMINAL_STATUSES.has(status);
15
+ }
16
+ /** Return whether an active record has a live session that can be opened. */
17
+ export function canOpenActiveAgent(record) {
18
+ return (record.status === "running" || record.status === "queued")
19
+ && record.session !== undefined;
20
+ }
21
+ /** Return whether a terminal record has an in-memory or durable conversation. */
22
+ export function canOpenAgentHistory(record, cwd) {
23
+ if (!isTerminalAgentStatus(record.status))
24
+ return false;
25
+ return record.session !== undefined
26
+ || (cwd !== undefined && hasAgentHistory(cwd, record.transcriptPath));
27
+ }
28
+ /** Split records into openable active and terminal-history buckets. */
29
+ export function splitAgentRecords(records, cwd) {
30
+ const active = [];
31
+ const history = [];
32
+ for (const record of records) {
33
+ if (canOpenActiveAgent(record)) {
34
+ active.push(record);
35
+ }
36
+ else if (canOpenAgentHistory(record, cwd)) {
37
+ history.push(record);
38
+ }
39
+ }
40
+ return { active, history };
41
+ }
42
+ const DESCRIPTION_LIMIT = 72;
43
+ const LABEL_LIMIT = 140;
44
+ function boundedDescription(description) {
45
+ const line = description.split("\n").find((part) => part.trim())?.trim() ?? "";
46
+ if (line.length <= DESCRIPTION_LIMIT)
47
+ return line;
48
+ return `${line.slice(0, DESCRIPTION_LIMIT - 1)}…`;
49
+ }
50
+ /** Format a bounded, single-line history menu option. */
51
+ export function formatAgentHistoryOption(record, now) {
52
+ const duration = formatDuration(record.startedAt, record.completedAt ?? now);
53
+ // Unit consumers may format persisted records before the registry has been
54
+ // initialized; preserve the persisted type instead of falling back to
55
+ // general-purpose's generic display name in that case.
56
+ const displayName = getAgentConfig(record.type)?.displayName
57
+ ?? (record.type === "general-purpose" ? getDisplayName(record.type) : record.type);
58
+ const option = `[${record.status}] ${displayName} · ${boundedDescription(record.description)} · ${duration}`;
59
+ return option.length <= LABEL_LIMIT ? option : `${option.slice(0, LABEL_LIMIT - 1)}…`;
60
+ }
61
+ /** Build the visible status-section labels for the agents menu. */
62
+ export function buildAgentStatusMenuEntries(records, cwd) {
63
+ const { active, history } = splitAgentRecords(records, cwd);
64
+ return [
65
+ ...(active.length > 0 ? [`Running agents (${active.length})`] : []),
66
+ ...(history.length > 0 ? [`Agent history (${history.length})`] : []),
67
+ ];
68
+ }
@@ -9,7 +9,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
9
  */
10
10
  export declare function ensureSubagentsGitignore(cwd: string): string;
11
11
  /** Return the durable transcript path for an agent. */
12
- export declare function createAgentHistoryPath(cwd: string, agentId: string, agentType?: string): string;
12
+ export declare function createAgentHistoryPath(cwd: string, agentId: string): string;
13
13
  /** Return the project-relative path stored in the parent session record. */
14
14
  export declare function agentHistoryLocator(cwd: string, historyPath: string): string;
15
15
  /** Resolve only paths in this package's project-local transcript namespace. */
@@ -1,14 +1,6 @@
1
- "use strict";
2
1
  /** Durable, project-local transcript storage for subagents. */
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.ensureSubagentsGitignore = ensureSubagentsGitignore;
5
- exports.createAgentHistoryPath = createAgentHistoryPath;
6
- exports.agentHistoryLocator = agentHistoryLocator;
7
- exports.resolveAgentHistoryPath = resolveAgentHistoryPath;
8
- exports.hasAgentHistory = hasAgentHistory;
9
- exports.readAgentHistory = readAgentHistory;
10
- const node_fs_1 = require("node:fs");
11
- const node_path_1 = require("node:path");
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
12
4
  const SUBAGENTS_DIR = ".pi-subagents";
13
5
  const TRANSCRIPTS_DIR = "agent-transcripts";
14
6
  const MAX_HISTORY_BYTES = 20 * 1024 * 1024;
@@ -19,65 +11,70 @@ const MAX_HISTORY_BYTES = 20 * 1024 * 1024;
19
11
  * existing user-owned rules and makes concurrent callers harmless (duplicate
20
12
  * `*` rules are semantically equivalent).
21
13
  */
22
- function ensureSubagentsGitignore(cwd) {
23
- const directory = (0, node_path_1.join)(cwd, SUBAGENTS_DIR);
24
- (0, node_fs_1.mkdirSync)(directory, { recursive: true });
25
- const path = (0, node_path_1.join)(directory, ".gitignore");
14
+ export function ensureSubagentsGitignore(cwd) {
15
+ const directory = join(cwd, SUBAGENTS_DIR);
16
+ mkdirSync(directory, { recursive: true });
17
+ const path = join(directory, ".gitignore");
26
18
  let content = "";
27
19
  try {
28
- content = (0, node_fs_1.readFileSync)(path, "utf8");
20
+ content = readFileSync(path, "utf8");
29
21
  }
30
22
  catch {
31
23
  // The append below also creates a missing file.
32
24
  }
33
25
  if (!content.split(/\r?\n/).some((line) => line.trim() === "*")) {
34
26
  const separator = content.length > 0 && !/[\r\n]$/.test(content) ? "\n" : "";
35
- (0, node_fs_1.appendFileSync)(path, `${separator}*\n`, "utf8");
27
+ appendFileSync(path, `${separator}*\n`, "utf8");
36
28
  }
37
29
  return path;
38
30
  }
39
31
  /** Return the durable transcript path for an agent. */
40
- function createAgentHistoryPath(cwd, agentId, agentType = "agent") {
32
+ export function createAgentHistoryPath(cwd, agentId) {
41
33
  ensureSubagentsGitignore(cwd);
42
- const directory = (0, node_path_1.join)(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
43
- (0, node_fs_1.mkdirSync)(directory, { recursive: true });
34
+ const directory = join(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
35
+ mkdirSync(directory, { recursive: true });
44
36
  const safeId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
45
- const safeType = agentType.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
46
- return (0, node_path_1.join)(directory, `${safeId}_${safeType}_transcript.jsonl`);
37
+ return join(directory, `${safeId}.jsonl`);
47
38
  }
48
39
  /** Return the project-relative path stored in the parent session record. */
49
- function agentHistoryLocator(cwd, historyPath) {
50
- return (0, node_path_1.relative)(cwd, historyPath).split(node_path_1.sep).join("/");
40
+ export function agentHistoryLocator(cwd, historyPath) {
41
+ return relative(cwd, historyPath).split(sep).join("/");
51
42
  }
52
43
  /** Resolve only paths in this package's project-local transcript namespace. */
53
- function resolveAgentHistoryPath(cwd, locator) {
54
- if (!locator || (0, node_path_1.isAbsolute)(locator))
44
+ export function resolveAgentHistoryPath(cwd, locator) {
45
+ // Locators are persisted data, not arbitrary paths. Reject traversal syntax
46
+ // before resolving so a path which normalizes back inside the namespace is
47
+ // still not accepted as an unsafe locator.
48
+ if (!locator || isAbsolute(locator) || locator.includes("\0") || locator.includes("\\"))
55
49
  return undefined;
56
- const root = (0, node_path_1.resolve)(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
57
- const candidate = (0, node_path_1.resolve)(cwd, locator);
58
- if (candidate !== root && !candidate.startsWith(`${root}${node_path_1.sep}`))
50
+ const segments = locator.split("/");
51
+ if (segments.some((segment) => segment === ".." || segment === ""))
52
+ return undefined;
53
+ const root = resolve(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
54
+ const candidate = resolve(cwd, locator);
55
+ if (candidate !== root && !candidate.startsWith(`${root}${sep}`))
59
56
  return undefined;
60
57
  return candidate;
61
58
  }
62
59
  /** Return true only when a valid project-local transcript file exists. */
63
- function hasAgentHistory(cwd, locator) {
60
+ export function hasAgentHistory(cwd, locator) {
64
61
  if (!locator)
65
62
  return false;
66
63
  const path = resolveAgentHistoryPath(cwd, locator);
67
- return path !== undefined && (0, node_fs_1.existsSync)(path);
64
+ return path !== undefined && existsSync(path);
68
65
  }
69
66
  /**
70
67
  * Read persisted transcript entries into the message shape used by the live
71
68
  * conversation viewer. Malformed lines and unknown records are skipped so one
72
69
  * damaged entry cannot hide the rest of a history.
73
70
  */
74
- function readAgentHistory(cwd, locator) {
71
+ export function readAgentHistory(cwd, locator) {
75
72
  const path = resolveAgentHistoryPath(cwd, locator);
76
- if (!path)
73
+ if (!path || !existsSync(path))
77
74
  return undefined;
78
75
  let raw;
79
76
  try {
80
- raw = (0, node_fs_1.readFileSync)(path, "utf8");
77
+ raw = readFileSync(path, "utf8");
81
78
  }
82
79
  catch {
83
80
  return undefined;
@@ -90,7 +87,7 @@ function readAgentHistory(cwd, locator) {
90
87
  continue;
91
88
  try {
92
89
  const entry = JSON.parse(line);
93
- const message = entry.message;
90
+ const message = entry?.message;
94
91
  if (!message || typeof message !== "object")
95
92
  continue;
96
93
  const role = message.role;
@@ -16,6 +16,25 @@ export type CompactionInfo = {
16
16
  reason: "manual" | "threshold" | "overflow";
17
17
  tokensBefore: number;
18
18
  };
19
+ type RestorableAgentStatus = "completed" | "steered" | "stopped" | "aborted" | "error";
20
+ /** Validate one persisted terminal record without constructing runtime handles. */
21
+ export declare function isRestorableAgentRecord(value: unknown): value is {
22
+ id: string;
23
+ type: string;
24
+ description: string;
25
+ status: RestorableAgentStatus;
26
+ startedAt: number;
27
+ completedAt: number;
28
+ result?: string;
29
+ error?: string;
30
+ toolUses?: number;
31
+ lifetimeUsage?: {
32
+ input: number;
33
+ output: number;
34
+ cacheWrite: number;
35
+ };
36
+ transcriptPath?: string;
37
+ };
19
38
  interface SpawnOptions {
20
39
  description: string;
21
40
  model?: Model<any>;
@@ -121,15 +140,10 @@ export declare class AgentManager {
121
140
  */
122
141
  steer(id: string, message: string): boolean;
123
142
  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;
132
143
  listAgents(): AgentRecord[];
144
+ /** Restore terminal records persisted by a parent branch without runtime handles. */
145
+ restoreCompleted(records: readonly unknown[]): void;
146
+ private createRestoredRecord;
133
147
  abort(id: string): boolean;
134
148
  /** Dispose a record's session and remove it from the map. */
135
149
  private removeRecord;
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /**
3
2
  * agent-manager.ts — Tracks agents, background execution, resume support.
4
3
  *
@@ -6,23 +5,64 @@
6
5
  * Excess agents are queued and auto-started as running agents complete.
7
6
  * Foreground agents bypass the queue (they block the parent anyway).
8
7
  */
9
- Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.AgentManager = void 0;
11
- const node_crypto_1 = require("node:crypto");
12
- const node_fs_1 = require("node:fs");
13
- const node_path_1 = require("node:path");
14
- const agent_runner_js_1 = require("./agent-runner.js");
15
- const usage_js_1 = require("./usage.js");
16
- const worktree_js_1 = require("./worktree.js");
8
+ import { randomUUID } from "node:crypto";
9
+ import { statSync } from "node:fs";
10
+ import { isAbsolute } from "node:path";
11
+ import { resumeAgent, runAgent } from "./agent-runner.js";
12
+ import { addUsage } from "./usage.js";
13
+ import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
17
14
  /** Default max concurrent background agents. */
18
15
  const DEFAULT_MAX_CONCURRENT = 4;
19
- const TERMINAL_STATUSES = new Set([
20
- "completed",
21
- "steered",
22
- "aborted",
23
- "stopped",
24
- "error",
25
- ]);
16
+ const RESTORABLE_STATUSES = new Set(["completed", "steered", "stopped", "aborted", "error"]);
17
+ /** Narrow persisted strings before putting them into runtime/UI state. */
18
+ function isSafePersistedString(value, maxLength) {
19
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength && !/[\0\r\n]/.test(value);
20
+ }
21
+ function isFiniteTimestamp(value) {
22
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
23
+ }
24
+ function isValidUsage(value) {
25
+ if (!value || typeof value !== "object")
26
+ return false;
27
+ const usage = value;
28
+ return ["input", "output", "cacheWrite"].every((key) => {
29
+ const n = usage[key];
30
+ return typeof n === "number" && Number.isFinite(n) && n >= 0;
31
+ });
32
+ }
33
+ function isSafeTranscriptLocator(value) {
34
+ return typeof value === "string"
35
+ && /^\.pi-subagents\/agent-transcripts\/[^/]+\.jsonl$/.test(value)
36
+ && !value.includes("..")
37
+ && !value.includes("\\")
38
+ && !value.includes("\0");
39
+ }
40
+ /** Validate one persisted terminal record without constructing runtime handles. */
41
+ export function isRestorableAgentRecord(value) {
42
+ if (!value || typeof value !== "object")
43
+ return false;
44
+ const record = value;
45
+ if (!isSafePersistedString(record.id, 256)
46
+ || !isSafePersistedString(record.type, 256)
47
+ || !isSafePersistedString(record.description, 4096)
48
+ || typeof record.status !== "string"
49
+ || !RESTORABLE_STATUSES.has(record.status)
50
+ || !isFiniteTimestamp(record.startedAt)
51
+ || !isFiniteTimestamp(record.completedAt)
52
+ || record.completedAt < record.startedAt)
53
+ return false;
54
+ if (record.result !== undefined && !isSafePersistedString(record.result, 2_000_000))
55
+ return false;
56
+ if (record.error !== undefined && !isSafePersistedString(record.error, 64_000))
57
+ return false;
58
+ if (record.toolUses !== undefined && (!Number.isInteger(record.toolUses) || record.toolUses < 0))
59
+ return false;
60
+ if (record.lifetimeUsage !== undefined && !isValidUsage(record.lifetimeUsage))
61
+ return false;
62
+ if (record.transcriptPath !== undefined && !isSafeTranscriptLocator(record.transcriptPath))
63
+ return false;
64
+ return true;
65
+ }
26
66
  /**
27
67
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
28
68
  * (parent cwd). Anything else must be an absolute path to an existing
@@ -32,12 +72,12 @@ const TERMINAL_STATUSES = new Set([
32
72
  function assertValidSpawnCwd(cwd) {
33
73
  if (cwd == null)
34
74
  return;
35
- if (typeof cwd !== "string" || !(0, node_path_1.isAbsolute)(cwd)) {
75
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
36
76
  throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
37
77
  }
38
78
  let isDirectory = false;
39
79
  try {
40
- isDirectory = (0, node_fs_1.statSync)(cwd).isDirectory();
80
+ isDirectory = statSync(cwd).isDirectory();
41
81
  }
42
82
  catch {
43
83
  throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
@@ -46,37 +86,7 @@ function assertValidSpawnCwd(cwd) {
46
86
  throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
47
87
  }
48
88
  }
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
- const transcriptPath = record.transcriptPath;
61
- const validTranscriptPath = transcriptPath === undefined ||
62
- (typeof transcriptPath === "string" &&
63
- transcriptPath.startsWith(".pi-subagents/agent-transcripts/") &&
64
- !transcriptPath.includes("..") &&
65
- !transcriptPath.includes("\\"));
66
- return (typeof record.id === "string" &&
67
- record.id.length > 0 &&
68
- typeof record.type === "string" &&
69
- typeof record.description === "string" &&
70
- RESTORABLE_STATUSES.has(record.status) &&
71
- typeof record.startedAt === "number" &&
72
- Number.isFinite(record.startedAt) &&
73
- typeof record.completedAt === "number" &&
74
- Number.isFinite(record.completedAt) &&
75
- (record.result === undefined || typeof record.result === "string") &&
76
- (record.error === undefined || typeof record.error === "string") &&
77
- validTranscriptPath);
78
- }
79
- class AgentManager {
89
+ export class AgentManager {
80
90
  agents = new Map();
81
91
  cleanupInterval;
82
92
  onComplete;
@@ -117,7 +127,7 @@ class AgentManager {
117
127
  // call, not minutes later at drain. Throw (not warn): programmatic callers
118
128
  // can fix and retry; the RPC layer converts throws into error envelopes.
119
129
  assertValidSpawnCwd(options.cwd);
120
- const id = (0, node_crypto_1.randomUUID)().slice(0, 17);
130
+ const id = randomUUID().slice(0, 17);
121
131
  const abortController = new AbortController();
122
132
  const record = {
123
133
  id,
@@ -170,7 +180,7 @@ class AgentManager {
170
180
  // BEFORE state mutation so a throw doesn't leave the record half-running.
171
181
  let worktreeCwd;
172
182
  if (options.isolation === "worktree") {
173
- const wt = (0, worktree_js_1.createWorktree)(baseCwd, id);
183
+ const wt = createWorktree(baseCwd, id);
174
184
  if (!wt) {
175
185
  throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
176
186
  'Initialize git and commit at least once, or omit `isolation`.');
@@ -198,7 +208,7 @@ class AgentManager {
198
208
  detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
199
209
  }
200
210
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
201
- const promise = (0, agent_runner_js_1.runAgent)(ctx, type, prompt, {
211
+ const promise = runAgent(ctx, type, prompt, {
202
212
  pi,
203
213
  agentId: id,
204
214
  model: options.model,
@@ -222,7 +232,7 @@ class AgentManager {
222
232
  onTurnEnd: options.onTurnEnd,
223
233
  onTextDelta: options.onTextDelta,
224
234
  onAssistantUsage: (usage) => {
225
- (0, usage_js_1.addUsage)(record.lifetimeUsage, usage);
235
+ addUsage(record.lifetimeUsage, usage);
226
236
  options.onAssistantUsage?.(usage);
227
237
  },
228
238
  onCompaction: (info) => {
@@ -273,7 +283,7 @@ class AgentManager {
273
283
  }
274
284
  // Clean up worktree if used
275
285
  if (record.worktree) {
276
- const wtResult = (0, worktree_js_1.cleanupWorktree)(baseCwd, record.worktree, options.description);
286
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
277
287
  record.worktreeResult = wtResult;
278
288
  if (wtResult.hasChanges && wtResult.branch) {
279
289
  // With a caller-supplied cwd the branch lives in THAT repo, not the
@@ -321,7 +331,7 @@ class AgentManager {
321
331
  // Best-effort worktree cleanup on error
322
332
  if (record.worktree) {
323
333
  try {
324
- const wtResult = (0, worktree_js_1.cleanupWorktree)(baseCwd, record.worktree, options.description);
334
+ const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description);
325
335
  record.worktreeResult = wtResult;
326
336
  }
327
337
  catch { /* ignore cleanup errors */ }
@@ -406,13 +416,13 @@ class AgentManager {
406
416
  record.result = undefined;
407
417
  record.error = undefined;
408
418
  try {
409
- const { text, failure } = await (0, agent_runner_js_1.resumeAgent)(record.session, prompt, {
419
+ const { text, failure } = await resumeAgent(record.session, prompt, {
410
420
  onToolActivity: (activity) => {
411
421
  if (activity.type === "end")
412
422
  record.toolUses++;
413
423
  },
414
424
  onAssistantUsage: (usage) => {
415
- (0, usage_js_1.addUsage)(record.lifetimeUsage, usage);
425
+ addUsage(record.lifetimeUsage, usage);
416
426
  },
417
427
  onCompaction: (info) => {
418
428
  record.compactionCount++;
@@ -462,43 +472,41 @@ class AgentManager {
462
472
  getRecord(id) {
463
473
  return this.agents.get(id);
464
474
  }
465
- /**
466
- * Restore terminal records persisted in a parent session.
467
- *
468
- * Restored records deliberately have no live session, promise, or abort
469
- * controller. Invalid data is ignored because session entries are persisted
470
- * extension data and may have been written by an older version.
471
- */
475
+ listAgents() {
476
+ return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
477
+ }
478
+ /** Restore terminal records persisted by a parent branch without runtime handles. */
472
479
  restoreCompleted(records) {
473
- const restoredIds = new Set();
474
- // getBranch() is chronological; newest persisted state wins on duplicate IDs.
475
- for (const value of [...records].reverse()) {
476
- if (!isRestorableRecord(value))
477
- continue;
478
- if (restoredIds.has(value.id) || this.agents.has(value.id))
480
+ const latest = new Map();
481
+ for (const value of records) {
482
+ if (isRestorableAgentRecord(value)) {
483
+ latest.set(value.id, this.createRestoredRecord(value));
484
+ }
485
+ }
486
+ for (const [id, restored] of latest) {
487
+ const existing = this.agents.get(id);
488
+ if (existing?.status === "running" || existing?.status === "queued")
479
489
  continue;
480
- restoredIds.add(value.id);
481
- this.agents.set(value.id, {
482
- id: value.id,
483
- type: value.type,
484
- description: value.description,
485
- status: value.status,
486
- result: value.result,
487
- error: value.error,
488
- transcriptPath: value.transcriptPath,
489
- toolUses: 0,
490
- startedAt: value.startedAt,
491
- completedAt: value.completedAt,
492
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
493
- compactionCount: 0,
494
- // Historical records have no inline tool surface and should remain
495
- // visible in the background widget.
496
- isBackground: true,
497
- });
490
+ this.agents.set(id, restored);
498
491
  }
499
492
  }
500
- listAgents() {
501
- return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
493
+ createRestoredRecord(record) {
494
+ return {
495
+ id: record.id,
496
+ type: record.type,
497
+ description: record.description,
498
+ status: record.status,
499
+ result: record.result,
500
+ error: record.error,
501
+ toolUses: record.toolUses ?? 0,
502
+ startedAt: record.startedAt,
503
+ completedAt: record.completedAt,
504
+ transcriptPath: record.transcriptPath,
505
+ lifetimeUsage: record.lifetimeUsage
506
+ ? { ...record.lifetimeUsage }
507
+ : { input: 0, output: 0, cacheWrite: 0 },
508
+ compactionCount: 0,
509
+ };
502
510
  }
503
511
  abort(id) {
504
512
  const record = this.agents.get(id);
@@ -602,17 +610,16 @@ class AgentManager {
602
610
  this.agents.clear();
603
611
  // Prune any orphaned git worktrees (crash recovery)
604
612
  try {
605
- (0, worktree_js_1.pruneWorktrees)(process.cwd());
613
+ pruneWorktrees(process.cwd());
606
614
  }
607
615
  catch { /* ignore */ }
608
616
  // Also prune repos that caller-supplied cwds created worktrees in — a clean
609
617
  // exit with in-flight agents would otherwise leave stale registrations there.
610
618
  for (const repo of this.worktreeRepos) {
611
619
  try {
612
- (0, worktree_js_1.pruneWorktrees)(repo);
620
+ pruneWorktrees(repo);
613
621
  }
614
622
  catch { /* ignore */ }
615
623
  }
616
624
  }
617
625
  }
618
- exports.AgentManager = AgentManager;