@tintinweb/pi-subagents 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.3] - 2026-03-13
9
+
10
+ ### Added
11
+ - **Persistent agent memory** — new `memory` frontmatter field with three scopes: `"user"` (global `~/.pi/`), `"project"` (per-project `.pi/`), `"local"` (gitignored `.pi/`). Agents with write/edit tools get full read-write memory; read-only agents get a read-only fallback that injects existing MEMORY.md content without granting write access or creating directories.
12
+ - **Git worktree isolation** — new `isolation: "worktree"` frontmatter field and Agent tool parameter. Creates a temporary `git worktree` so agents work on an isolated copy of the repo. On completion, changes are auto-committed to a `pi-agent-<id>` branch; clean worktrees are removed. Includes crash recovery via `pruneWorktrees()`.
13
+ - **Skill preloading** — `skills` frontmatter now accepts a comma-separated list of skill names (e.g. `skills: planning, review`). Reads from `.pi/skills/` (project) then `~/.pi/skills/` (global), tries `.md`/`.txt`/bare extensions. Content injected into the system prompt as `# Preloaded Skill: {name}`.
14
+ - **Tool denylist** — new `disallowed_tools` frontmatter field (e.g. `disallowed_tools: bash, write`). Blocks specified tools even if `builtinToolNames` or extensions would provide them. Enforced for both extension-enabled and extension-disabled agents.
15
+ - **Prompt extras system** — new `PromptExtras` interface in `prompts.ts`; `buildAgentPrompt()` accepts optional memory and skill blocks appended in both `replace` and `append` modes.
16
+ - `getMemoryTools()`, `getReadOnlyMemoryTools()` in `agent-types.ts`.
17
+ - `buildMemoryBlock()`, `buildReadOnlyMemoryBlock()`, `isSymlink()`, `safeReadFile()` in `memory.ts`.
18
+ - `preloadSkills()` in `skill-loader.ts`.
19
+ - `createWorktree()`, `cleanupWorktree()`, `pruneWorktrees()` in `worktree.ts`.
20
+ - `MemoryScope`, `IsolationMode` types; `memory`, `isolation`, `disallowedTools` fields on `AgentConfig`; `worktree`, `worktreeResult` fields on `AgentRecord`.
21
+ - 177 total tests across 8 test files (41 new tests).
22
+
23
+ ### Fixed
24
+ - **Read-only agents no longer escalated to read-write** — enabling `memory` on a read-only agent (e.g. Explore) previously auto-added `write`/`edit` tools. Now the runner detects write capability and branches: read-write agents get full memory tools, read-only agents get read-only memory prompt with only the `read` tool added.
25
+ - **Denylist-aware memory detection** — write capability check now accounts for `disallowedTools`. An agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory instead of broken read-write instructions.
26
+ - **Worktree requires commits** — repos with no commits (empty HEAD) are now rejected early with a warning instead of failing silently at `git worktree add`.
27
+ - **Worktree failure warning** — when worktree creation fails, a warning is prepended to the agent's prompt instead of silently falling through to the main cwd.
28
+ - **No force-branch overwrite** — worktree cleanup appends a timestamp suffix on branch name conflict instead of using `git branch -f`.
29
+
30
+ ### Security
31
+ - **Whitelist name validation** — agent/skill names must match `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`, max 128 chars. Rejects path traversal, leading dots, spaces, and special characters.
32
+ - **Symlink protection** — `safeReadFile()` and `isSymlink()` reject symlinks in memory directories, MEMORY.md files, and skill files, preventing arbitrary file reads.
33
+ - **Symlink-safe directory creation** — `ensureMemoryDir()` throws on symlinked directories.
34
+
35
+ ### Changed
36
+ - `agent-runner.ts`: tool/extension/skill resolution moved before memory detection; `ctx.cwd` → `effectiveCwd` throughout.
37
+ - `custom-agents.ts`: extracted `parseCsvField()` helper; added `csvListOptional()` and `parseMemory()`.
38
+ - `skill-loader.ts`: uses `safeReadFile()` from `memory.ts` instead of raw `readFileSync`.
39
+ - Agent tool schema updated with `isolation` parameter and help text for `memory`, `isolation`, `disallowed_tools`, and skill list.
40
+
41
+ ## [0.4.2] - 2026-03-12
42
+
43
+ ### Added
44
+ - **Event bus** — agent lifecycle events emitted via `pi.events.emit()`, enabling other extensions to react to sub-agent activity:
45
+ - `subagents:created` — background agent registered (includes `id`, `type`, `description`, `isBackground`)
46
+ - `subagents:started` — agent transitions to running (includes queued→running)
47
+ - `subagents:completed` — agent finished successfully (includes `durationMs`, `tokens`, `toolUses`, `result`)
48
+ - `subagents:failed` — agent errored, stopped, or aborted (same payload as completed)
49
+ - `subagents:steered` — steering message sent to a running agent
50
+ - `OnAgentStart` callback and `onStart` constructor parameter on `AgentManager`.
51
+ - **Cross-package manager** now also exposes `spawn()` and `getRecord()` via the `Symbol.for("pi-subagents:manager")` global.
52
+
53
+ ## [0.4.1] - 2026-03-11
54
+
55
+ ### Fixed
56
+ - **Graceful shutdown in headless mode** — the CLI now waits for all running and queued background agents to complete before exiting (`waitForAll` on `session_shutdown`). Previously, background agents could be silently killed mid-execution when the session ended. Only affects headless/non-interactive mode; interactive sessions already kept the process alive.
57
+
58
+ ### Added
59
+ - `hasRunning()` / `waitForAll()` methods on `AgentManager`.
60
+ - **Cross-package manager access** — agent manager exposed via `Symbol.for("pi-subagents:manager")` on `globalThis` for other extensions to check status or await completion.
61
+
8
62
  ## [0.4.0] - 2026-03-11
9
63
 
10
64
  ### Added
@@ -188,6 +242,9 @@ Initial release.
188
242
  - **Thinking level** — per-agent extended thinking control
189
243
  - **`/agent` and `/agents` commands**
190
244
 
245
+ [0.4.3]: https://github.com/tintinweb/pi-subagents/compare/v0.4.2...v0.4.3
246
+ [0.4.2]: https://github.com/tintinweb/pi-subagents/compare/v0.4.1...v0.4.2
247
+ [0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
191
248
  [0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
192
249
  [0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1
193
250
  [0.3.0]: https://github.com/tintinweb/pi-subagents/compare/v0.2.7...v0.3.0
package/README.md CHANGED
@@ -23,6 +23,11 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
23
23
  - **Case-insensitive agent types** — `"explore"`, `"Explore"`, `"EXPLORE"` all work. Unknown types fall back to general-purpose with a note
24
24
  - **Fuzzy model selection** — specify models by name (`"haiku"`, `"sonnet"`) instead of full IDs, with automatic filtering to only available/configured models
25
25
  - **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed
26
+ - **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools
27
+ - **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
28
+ - **Skill preloading** — inject named skill files from `.pi/skills/` into agent system prompts
29
+ - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
30
+ - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
26
31
 
27
32
  ## Install
28
33
 
@@ -138,13 +143,17 @@ All fields are optional — sensible defaults for everything.
138
143
  | `display_name` | — | Display name for UI (e.g. widget, agent list) |
139
144
  | `tools` | all 7 | Comma-separated built-in tools: read, bash, edit, write, grep, find, ls. `none` for no tools |
140
145
  | `extensions` | `true` | Inherit MCP/extension tools. `false` to disable |
141
- | `skills` | `true` | Inherit skills from parent |
146
+ | `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload from `.pi/skills/` |
147
+ | `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
148
+ | `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
149
+ | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
142
150
  | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
143
151
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh |
144
152
  | `max_turns` | 50 | Max agentic turns before graceful shutdown |
145
153
  | `prompt_mode` | `replace` | `replace`: body is the full system prompt. `append`: body appended to parent's prompt (agent acts as a "parent twin" with optional extra instructions) |
146
154
  | `inherit_context` | `false` | Fork parent conversation into agent |
147
155
  | `run_in_background` | `false` | Run in background by default |
156
+ | `isolation` | — | `worktree`: run in a temporary git worktree for full repo isolation |
148
157
  | `isolated` | `false` | No extension/MCP tools, only built-in |
149
158
  | `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
150
159
 
@@ -167,6 +176,7 @@ Launch a sub-agent.
167
176
  | `run_in_background` | boolean | no | Run without blocking |
168
177
  | `resume` | string | no | Agent ID to resume a previous session |
169
178
  | `isolated` | boolean | no | No extension/MCP tools |
179
+ | `isolation` | `"worktree"` | no | Run in an isolated git worktree |
170
180
  | `inherit_context` | boolean | no | Fork parent conversation into agent |
171
181
  | `join_mode` | `"async"` \| `"group"` | no | Override join strategy for background completion notifications (default: smart) |
172
182
 
@@ -251,6 +261,77 @@ When background agents complete, they notify the main agent. The **join mode** c
251
261
  - Per-call: `Agent({ ..., join_mode: "async" })` overrides for that agent
252
262
  - Global default: `/agents` → Settings → Join mode
253
263
 
264
+ ## Events
265
+
266
+ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions can react:
267
+
268
+ | Event | When | Key fields |
269
+ |-------|------|------------|
270
+ | `subagents:created` | Background agent registered | `id`, `type`, `description`, `isBackground` |
271
+ | `subagents:started` | Agent transitions to running (including queued→running) | `id`, `type`, `description` |
272
+ | `subagents:completed` | Agent finished successfully | `id`, `type`, `durationMs`, `tokens`, `toolUses`, `result` |
273
+ | `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
274
+ | `subagents:steered` | Steering message sent | `id`, `message` |
275
+
276
+ ## Persistent Agent Memory
277
+
278
+ Agents can have persistent memory across sessions. Set `memory` in frontmatter to enable:
279
+
280
+ ```yaml
281
+ ---
282
+ memory: project # project | local | user
283
+ ---
284
+ ```
285
+
286
+ | Scope | Location | Use case |
287
+ |-------|----------|----------|
288
+ | `project` | `.pi/agent-memory/<name>/` | Shared across the team (committed) |
289
+ | `local` | `.pi/agent-memory-local/<name>/` | Machine-specific (gitignored) |
290
+ | `user` | `~/.pi/agent-memory/<name>/` | Global personal memory |
291
+
292
+ Memory uses a `MEMORY.md` index file and individual memory files with frontmatter. Agents with write tools get full read-write access. **Read-only agents** (no `write`/`edit` tools) automatically get read-only memory — they can consume memories written by other agents but cannot modify them. This prevents unintended tool escalation.
293
+
294
+ The `disallowed_tools` field is respected when determining write capability — an agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory.
295
+
296
+ ## Worktree Isolation
297
+
298
+ Set `isolation: worktree` to run an agent in a temporary git worktree:
299
+
300
+ ```
301
+ Agent({ subagent_type: "refactor", prompt: "...", isolation: "worktree" })
302
+ ```
303
+
304
+ The agent gets a full, isolated copy of the repository. On completion:
305
+ - **No changes:** worktree is cleaned up automatically
306
+ - **Changes made:** changes are committed to a new branch (`pi-agent-<id>`) and returned in the result
307
+
308
+ If the worktree cannot be created (not a git repo, no commits), the agent falls back to the main working directory with a warning.
309
+
310
+ ## Skill Preloading
311
+
312
+ Skills can be preloaded as named files from `.pi/skills/` or `~/.pi/skills/`:
313
+
314
+ ```yaml
315
+ ---
316
+ skills: api-conventions, error-handling
317
+ ---
318
+ ```
319
+
320
+ Skill files (`.md`, `.txt`, or extensionless) are read and injected into the agent's system prompt. Project-level skills take priority over global ones. Symlinked skill files are rejected for security.
321
+
322
+ ## Tool Denylist
323
+
324
+ Block specific tools from an agent even if extensions provide them:
325
+
326
+ ```yaml
327
+ ---
328
+ tools: read, bash, grep, write
329
+ disallowed_tools: write, edit
330
+ ---
331
+ ```
332
+
333
+ This is useful for creating agents that inherit extension tools but should not have write access.
334
+
254
335
  ## Architecture
255
336
 
256
337
  ```
@@ -263,6 +344,9 @@ src/
263
344
  agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
264
345
  group-join.ts # Group join manager: batched completion notifications with timeout
265
346
  custom-agents.ts # Load user-defined agents from .pi/agents/*.md
347
+ memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
348
+ skill-loader.ts # Preload skill files from .pi/skills/
349
+ worktree.ts # Git worktree isolation (create, cleanup, prune)
266
350
  prompts.ts # Config-driven system prompt builder
267
351
  context.ts # Parent conversation context for inherit_context
268
352
  env.ts # Environment detection (git, platform)
@@ -0,0 +1,70 @@
1
+ /**
2
+ * agent-manager.ts — Tracks agents, background execution, resume support.
3
+ *
4
+ * Background agents are subject to a configurable concurrency limit (default: 4).
5
+ * Excess agents are queued and auto-started as running agents complete.
6
+ * Foreground agents bypass the queue (they block the parent anyway).
7
+ */
8
+ import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import type { Model } from "@mariozechner/pi-ai";
10
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
11
+ import { type ToolActivity } from "./agent-runner.js";
12
+ import type { SubagentType, AgentRecord, ThinkingLevel } from "./types.js";
13
+ export type OnAgentComplete = (record: AgentRecord) => void;
14
+ interface SpawnOptions {
15
+ description: string;
16
+ model?: Model<any>;
17
+ maxTurns?: number;
18
+ isolated?: boolean;
19
+ inheritContext?: boolean;
20
+ thinkingLevel?: ThinkingLevel;
21
+ isBackground?: boolean;
22
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
23
+ onToolActivity?: (activity: ToolActivity) => void;
24
+ /** Called on streaming text deltas from the assistant response. */
25
+ onTextDelta?: (delta: string, fullText: string) => void;
26
+ /** Called when the agent session is created (for accessing session stats). */
27
+ onSessionCreated?: (session: AgentSession) => void;
28
+ }
29
+ export declare class AgentManager {
30
+ private agents;
31
+ private cleanupInterval;
32
+ private onComplete?;
33
+ private maxConcurrent;
34
+ /** Queue of background agents waiting to start. */
35
+ private queue;
36
+ /** Number of currently running background agents. */
37
+ private runningBackground;
38
+ constructor(onComplete?: OnAgentComplete, maxConcurrent?: number);
39
+ /** Update the max concurrent background agents limit. */
40
+ setMaxConcurrent(n: number): void;
41
+ getMaxConcurrent(): number;
42
+ /**
43
+ * Spawn an agent and return its ID immediately (for background use).
44
+ * If the concurrency limit is reached, the agent is queued.
45
+ */
46
+ spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions): string;
47
+ /** Actually start an agent (called immediately or from queue drain). */
48
+ private startAgent;
49
+ /** Start queued agents up to the concurrency limit. */
50
+ private drainQueue;
51
+ /**
52
+ * Spawn an agent and wait for completion (foreground use).
53
+ * Foreground agents bypass the concurrency queue.
54
+ */
55
+ spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">): Promise<AgentRecord>;
56
+ /**
57
+ * Resume an existing agent session with a new prompt.
58
+ */
59
+ resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
60
+ getRecord(id: string): AgentRecord | undefined;
61
+ listAgents(): AgentRecord[];
62
+ abort(id: string): boolean;
63
+ private cleanup;
64
+ /** Whether any agents are still running or queued. */
65
+ hasRunning(): boolean;
66
+ /** Wait for all running and queued agents to complete (including queued ones). */
67
+ waitForAll(): Promise<void>;
68
+ dispose(): void;
69
+ }
70
+ export {};
@@ -0,0 +1,236 @@
1
+ /**
2
+ * agent-manager.ts — Tracks agents, background execution, resume support.
3
+ *
4
+ * Background agents are subject to a configurable concurrency limit (default: 4).
5
+ * Excess agents are queued and auto-started as running agents complete.
6
+ * Foreground agents bypass the queue (they block the parent anyway).
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import { runAgent, resumeAgent } from "./agent-runner.js";
10
+ /** Default max concurrent background agents. */
11
+ const DEFAULT_MAX_CONCURRENT = 4;
12
+ export class AgentManager {
13
+ agents = new Map();
14
+ cleanupInterval;
15
+ onComplete;
16
+ maxConcurrent;
17
+ /** Queue of background agents waiting to start. */
18
+ queue = [];
19
+ /** Number of currently running background agents. */
20
+ runningBackground = 0;
21
+ constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
22
+ this.onComplete = onComplete;
23
+ this.maxConcurrent = maxConcurrent;
24
+ // Cleanup completed agents after 10 minutes (but keep sessions for resume)
25
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
26
+ }
27
+ /** Update the max concurrent background agents limit. */
28
+ setMaxConcurrent(n) {
29
+ this.maxConcurrent = Math.max(1, n);
30
+ // Start queued agents if the new limit allows
31
+ this.drainQueue();
32
+ }
33
+ getMaxConcurrent() {
34
+ return this.maxConcurrent;
35
+ }
36
+ /**
37
+ * Spawn an agent and return its ID immediately (for background use).
38
+ * If the concurrency limit is reached, the agent is queued.
39
+ */
40
+ spawn(pi, ctx, type, prompt, options) {
41
+ const id = randomUUID().slice(0, 17);
42
+ const abortController = new AbortController();
43
+ const record = {
44
+ id,
45
+ type,
46
+ description: options.description,
47
+ status: options.isBackground ? "queued" : "running",
48
+ toolUses: 0,
49
+ startedAt: Date.now(),
50
+ abortController,
51
+ };
52
+ this.agents.set(id, record);
53
+ const args = { pi, ctx, type, prompt, options };
54
+ if (options.isBackground && this.runningBackground >= this.maxConcurrent) {
55
+ // Queue it — will be started when a running agent completes
56
+ this.queue.push({ id, args });
57
+ return id;
58
+ }
59
+ this.startAgent(id, record, args);
60
+ return id;
61
+ }
62
+ /** Actually start an agent (called immediately or from queue drain). */
63
+ startAgent(id, record, { pi, ctx, type, prompt, options }) {
64
+ record.status = "running";
65
+ record.startedAt = Date.now();
66
+ if (options.isBackground)
67
+ this.runningBackground++;
68
+ const promise = runAgent(ctx, type, prompt, {
69
+ pi,
70
+ model: options.model,
71
+ maxTurns: options.maxTurns,
72
+ isolated: options.isolated,
73
+ inheritContext: options.inheritContext,
74
+ thinkingLevel: options.thinkingLevel,
75
+ signal: record.abortController.signal,
76
+ onToolActivity: (activity) => {
77
+ if (activity.type === "end")
78
+ record.toolUses++;
79
+ options.onToolActivity?.(activity);
80
+ },
81
+ onTextDelta: options.onTextDelta,
82
+ onSessionCreated: (session) => {
83
+ record.session = session;
84
+ options.onSessionCreated?.(session);
85
+ },
86
+ })
87
+ .then(({ responseText, session, aborted, steered }) => {
88
+ // Don't overwrite status if externally stopped via abort()
89
+ if (record.status !== "stopped") {
90
+ record.status = aborted ? "aborted" : steered ? "steered" : "completed";
91
+ }
92
+ record.result = responseText;
93
+ record.session = session;
94
+ record.completedAt ??= Date.now();
95
+ if (options.isBackground) {
96
+ this.runningBackground--;
97
+ this.onComplete?.(record);
98
+ this.drainQueue();
99
+ }
100
+ return responseText;
101
+ })
102
+ .catch((err) => {
103
+ // Don't overwrite status if externally stopped via abort()
104
+ if (record.status !== "stopped") {
105
+ record.status = "error";
106
+ }
107
+ record.error = err instanceof Error ? err.message : String(err);
108
+ record.completedAt ??= Date.now();
109
+ if (options.isBackground) {
110
+ this.runningBackground--;
111
+ this.onComplete?.(record);
112
+ this.drainQueue();
113
+ }
114
+ return "";
115
+ });
116
+ record.promise = promise;
117
+ }
118
+ /** Start queued agents up to the concurrency limit. */
119
+ drainQueue() {
120
+ while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
121
+ const next = this.queue.shift();
122
+ const record = this.agents.get(next.id);
123
+ if (!record || record.status !== "queued")
124
+ continue;
125
+ this.startAgent(next.id, record, next.args);
126
+ }
127
+ }
128
+ /**
129
+ * Spawn an agent and wait for completion (foreground use).
130
+ * Foreground agents bypass the concurrency queue.
131
+ */
132
+ async spawnAndWait(pi, ctx, type, prompt, options) {
133
+ const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
134
+ const record = this.agents.get(id);
135
+ await record.promise;
136
+ return record;
137
+ }
138
+ /**
139
+ * Resume an existing agent session with a new prompt.
140
+ */
141
+ async resume(id, prompt, signal) {
142
+ const record = this.agents.get(id);
143
+ if (!record?.session)
144
+ return undefined;
145
+ record.status = "running";
146
+ record.startedAt = Date.now();
147
+ record.completedAt = undefined;
148
+ record.result = undefined;
149
+ record.error = undefined;
150
+ try {
151
+ const responseText = await resumeAgent(record.session, prompt, {
152
+ onToolActivity: (activity) => {
153
+ if (activity.type === "end")
154
+ record.toolUses++;
155
+ },
156
+ signal,
157
+ });
158
+ record.status = "completed";
159
+ record.result = responseText;
160
+ record.completedAt = Date.now();
161
+ }
162
+ catch (err) {
163
+ record.status = "error";
164
+ record.error = err instanceof Error ? err.message : String(err);
165
+ record.completedAt = Date.now();
166
+ }
167
+ return record;
168
+ }
169
+ getRecord(id) {
170
+ return this.agents.get(id);
171
+ }
172
+ listAgents() {
173
+ return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
174
+ }
175
+ abort(id) {
176
+ const record = this.agents.get(id);
177
+ if (!record)
178
+ return false;
179
+ // Remove from queue if queued
180
+ if (record.status === "queued") {
181
+ this.queue = this.queue.filter(q => q.id !== id);
182
+ record.status = "stopped";
183
+ record.completedAt = Date.now();
184
+ return true;
185
+ }
186
+ if (record.status !== "running")
187
+ return false;
188
+ record.abortController?.abort();
189
+ record.status = "stopped";
190
+ record.completedAt = Date.now();
191
+ return true;
192
+ }
193
+ cleanup() {
194
+ const cutoff = Date.now() - 10 * 60_000;
195
+ for (const [id, record] of this.agents) {
196
+ if (record.status === "running" || record.status === "queued")
197
+ continue;
198
+ if ((record.completedAt ?? 0) >= cutoff)
199
+ continue;
200
+ // Dispose and clear session so memory can be reclaimed
201
+ if (record.session) {
202
+ record.session.dispose();
203
+ record.session = undefined;
204
+ }
205
+ this.agents.delete(id);
206
+ }
207
+ }
208
+ /** Whether any agents are still running or queued. */
209
+ hasRunning() {
210
+ return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
211
+ }
212
+ /** Wait for all running and queued agents to complete (including queued ones). */
213
+ async waitForAll() {
214
+ // Loop because drainQueue respects the concurrency limit — as running
215
+ // agents finish they start queued ones, which need awaiting too.
216
+ while (true) {
217
+ this.drainQueue();
218
+ const pending = [...this.agents.values()]
219
+ .filter(r => r.status === "running" || r.status === "queued")
220
+ .map(r => r.promise)
221
+ .filter(Boolean);
222
+ if (pending.length === 0)
223
+ break;
224
+ await Promise.allSettled(pending);
225
+ }
226
+ }
227
+ dispose() {
228
+ clearInterval(this.cleanupInterval);
229
+ // Clear queue
230
+ this.queue = [];
231
+ for (const record of this.agents.values()) {
232
+ record.session?.dispose();
233
+ }
234
+ this.agents.clear();
235
+ }
236
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
+ */
4
+ import { type AgentSession, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import type { Model } from "@mariozechner/pi-ai";
7
+ import type { SubagentType, ThinkingLevel } from "./types.js";
8
+ /** Get the default max turns value. */
9
+ export declare function getDefaultMaxTurns(): number;
10
+ /** Set the default max turns value (minimum 1). */
11
+ export declare function setDefaultMaxTurns(n: number): void;
12
+ /** Get the grace turns value. */
13
+ export declare function getGraceTurns(): number;
14
+ /** Set the grace turns value (minimum 1). */
15
+ export declare function setGraceTurns(n: number): void;
16
+ /** Info about a tool event in the subagent. */
17
+ export interface ToolActivity {
18
+ type: "start" | "end";
19
+ toolName: string;
20
+ }
21
+ export interface RunOptions {
22
+ /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
23
+ pi: ExtensionAPI;
24
+ model?: Model<any>;
25
+ maxTurns?: number;
26
+ signal?: AbortSignal;
27
+ isolated?: boolean;
28
+ inheritContext?: boolean;
29
+ thinkingLevel?: ThinkingLevel;
30
+ /** Called on tool start/end with activity info. */
31
+ onToolActivity?: (activity: ToolActivity) => void;
32
+ /** Called on streaming text deltas from the assistant response. */
33
+ onTextDelta?: (delta: string, fullText: string) => void;
34
+ onSessionCreated?: (session: AgentSession) => void;
35
+ }
36
+ export interface RunResult {
37
+ responseText: string;
38
+ session: AgentSession;
39
+ /** True if the agent was hard-aborted (max_turns + grace exceeded). */
40
+ aborted: boolean;
41
+ /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
42
+ steered: boolean;
43
+ }
44
+ export declare function runAgent(ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
45
+ /**
46
+ * Send a new prompt to an existing session (resume).
47
+ */
48
+ export declare function resumeAgent(session: AgentSession, prompt: string, options?: {
49
+ onToolActivity?: (activity: ToolActivity) => void;
50
+ signal?: AbortSignal;
51
+ }): Promise<string>;
52
+ /**
53
+ * Send a steering message to a running subagent.
54
+ * The message will interrupt the agent after its current tool execution.
55
+ */
56
+ export declare function steerAgent(session: AgentSession, message: string): Promise<void>;
57
+ /**
58
+ * Get the subagent's conversation messages as formatted text.
59
+ */
60
+ export declare function getAgentConversation(session: AgentSession): string;