@tintinweb/pi-subagents 0.4.1 → 0.4.4

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,64 @@ 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.4] - 2026-03-16
9
+
10
+ ### Fixed
11
+ - **Race condition in `get_subagent_result` with `wait: true`** — `resultConsumed` is now set before `await record.promise`, preventing a redundant follow-up notification. Previously the `onComplete` callback (attached at spawn time via `.then()`) always fired before the await resumed, seeing `resultConsumed` as false.
12
+ - **Stale agent records across sessions** — new `clearCompleted()` method removes all completed/stopped/errored agent records on `session_start` and `session_switch` events, so tasks from a prior session don't persist into a new one.
13
+ - **`steer_subagent` race on freshly launched agents** — steering an agent before its session initialized silently dropped the message. Now steers are queued on the record and flushed once `onSessionCreated` fires.
14
+
15
+ ### Changed
16
+ - Extracted `removeRecord()` private helper in `AgentManager` — deduplicates dispose+delete logic between `cleanup()` and `clearCompleted()`.
17
+
18
+ ### Added
19
+ - 8 new tests covering `resultConsumed` race condition and `clearCompleted` behavior (185 total).
20
+
21
+ ## [0.4.3] - 2026-03-13
22
+
23
+ ### Added
24
+ - **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.
25
+ - **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()`.
26
+ - **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}`.
27
+ - **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.
28
+ - **Prompt extras system** — new `PromptExtras` interface in `prompts.ts`; `buildAgentPrompt()` accepts optional memory and skill blocks appended in both `replace` and `append` modes.
29
+ - `getMemoryTools()`, `getReadOnlyMemoryTools()` in `agent-types.ts`.
30
+ - `buildMemoryBlock()`, `buildReadOnlyMemoryBlock()`, `isSymlink()`, `safeReadFile()` in `memory.ts`.
31
+ - `preloadSkills()` in `skill-loader.ts`.
32
+ - `createWorktree()`, `cleanupWorktree()`, `pruneWorktrees()` in `worktree.ts`.
33
+ - `MemoryScope`, `IsolationMode` types; `memory`, `isolation`, `disallowedTools` fields on `AgentConfig`; `worktree`, `worktreeResult` fields on `AgentRecord`.
34
+ - 177 total tests across 8 test files (41 new tests).
35
+
36
+ ### Fixed
37
+ - **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.
38
+ - **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.
39
+ - **Worktree requires commits** — repos with no commits (empty HEAD) are now rejected early with a warning instead of failing silently at `git worktree add`.
40
+ - **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.
41
+ - **No force-branch overwrite** — worktree cleanup appends a timestamp suffix on branch name conflict instead of using `git branch -f`.
42
+
43
+ ### Security
44
+ - **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.
45
+ - **Symlink protection** — `safeReadFile()` and `isSymlink()` reject symlinks in memory directories, MEMORY.md files, and skill files, preventing arbitrary file reads.
46
+ - **Symlink-safe directory creation** — `ensureMemoryDir()` throws on symlinked directories.
47
+
48
+ ### Changed
49
+ - `agent-runner.ts`: tool/extension/skill resolution moved before memory detection; `ctx.cwd` → `effectiveCwd` throughout.
50
+ - `custom-agents.ts`: extracted `parseCsvField()` helper; added `csvListOptional()` and `parseMemory()`.
51
+ - `skill-loader.ts`: uses `safeReadFile()` from `memory.ts` instead of raw `readFileSync`.
52
+ - Agent tool schema updated with `isolation` parameter and help text for `memory`, `isolation`, `disallowed_tools`, and skill list.
53
+
54
+ ## [0.4.2] - 2026-03-12
55
+
56
+ ### Added
57
+ - **Event bus** — agent lifecycle events emitted via `pi.events.emit()`, enabling other extensions to react to sub-agent activity:
58
+ - `subagents:created` — background agent registered (includes `id`, `type`, `description`, `isBackground`)
59
+ - `subagents:started` — agent transitions to running (includes queued→running)
60
+ - `subagents:completed` — agent finished successfully (includes `durationMs`, `tokens`, `toolUses`, `result`)
61
+ - `subagents:failed` — agent errored, stopped, or aborted (same payload as completed)
62
+ - `subagents:steered` — steering message sent to a running agent
63
+ - `OnAgentStart` callback and `onStart` constructor parameter on `AgentManager`.
64
+ - **Cross-package manager** now also exposes `spawn()` and `getRecord()` via the `Symbol.for("pi-subagents:manager")` global.
65
+
8
66
  ## [0.4.1] - 2026-03-11
9
67
 
10
68
  ### Fixed
@@ -197,6 +255,9 @@ Initial release.
197
255
  - **Thinking level** — per-agent extended thinking control
198
256
  - **`/agent` and `/agents` commands**
199
257
 
258
+ [0.4.4]: https://github.com/tintinweb/pi-subagents/compare/v0.4.3...v0.4.4
259
+ [0.4.3]: https://github.com/tintinweb/pi-subagents/compare/v0.4.2...v0.4.3
260
+ [0.4.2]: https://github.com/tintinweb/pi-subagents/compare/v0.4.1...v0.4.2
200
261
  [0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
201
262
  [0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
202
263
  [0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.1",
3
+ "version": "0.4.4",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -11,9 +11,11 @@ import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-age
11
11
  import type { Model } from "@mariozechner/pi-ai";
12
12
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
13
13
  import { runAgent, resumeAgent, type ToolActivity } from "./agent-runner.js";
14
- import type { SubagentType, AgentRecord, ThinkingLevel } from "./types.js";
14
+ import type { SubagentType, AgentRecord, ThinkingLevel, IsolationMode } from "./types.js";
15
+ import { createWorktree, cleanupWorktree, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
15
16
 
16
17
  export type OnAgentComplete = (record: AgentRecord) => void;
18
+ export type OnAgentStart = (record: AgentRecord) => void;
17
19
 
18
20
  /** Default max concurrent background agents. */
19
21
  const DEFAULT_MAX_CONCURRENT = 4;
@@ -34,6 +36,8 @@ interface SpawnOptions {
34
36
  inheritContext?: boolean;
35
37
  thinkingLevel?: ThinkingLevel;
36
38
  isBackground?: boolean;
39
+ /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
40
+ isolation?: IsolationMode;
37
41
  /** Called on tool start/end with activity info (for streaming progress to UI). */
38
42
  onToolActivity?: (activity: ToolActivity) => void;
39
43
  /** Called on streaming text deltas from the assistant response. */
@@ -46,6 +50,7 @@ export class AgentManager {
46
50
  private agents = new Map<string, AgentRecord>();
47
51
  private cleanupInterval: ReturnType<typeof setInterval>;
48
52
  private onComplete?: OnAgentComplete;
53
+ private onStart?: OnAgentStart;
49
54
  private maxConcurrent: number;
50
55
 
51
56
  /** Queue of background agents waiting to start. */
@@ -53,8 +58,9 @@ export class AgentManager {
53
58
  /** Number of currently running background agents. */
54
59
  private runningBackground = 0;
55
60
 
56
- constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
61
+ constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart) {
57
62
  this.onComplete = onComplete;
63
+ this.onStart = onStart;
58
64
  this.maxConcurrent = maxConcurrent;
59
65
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
60
66
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
@@ -112,14 +118,32 @@ export class AgentManager {
112
118
  record.status = "running";
113
119
  record.startedAt = Date.now();
114
120
  if (options.isBackground) this.runningBackground++;
121
+ this.onStart?.(record);
122
+
123
+ // Worktree isolation: create a temporary git worktree if requested
124
+ let worktreeCwd: string | undefined;
125
+ let worktreeWarning = "";
126
+ if (options.isolation === "worktree") {
127
+ const wt = createWorktree(ctx.cwd, id);
128
+ if (wt) {
129
+ record.worktree = wt;
130
+ worktreeCwd = wt.path;
131
+ } else {
132
+ worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
133
+ }
134
+ }
135
+
136
+ // Prepend worktree warning to prompt if isolation failed
137
+ const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
115
138
 
116
- const promise = runAgent(ctx, type, prompt, {
139
+ const promise = runAgent(ctx, type, effectivePrompt, {
117
140
  pi,
118
141
  model: options.model,
119
142
  maxTurns: options.maxTurns,
120
143
  isolated: options.isolated,
121
144
  inheritContext: options.inheritContext,
122
145
  thinkingLevel: options.thinkingLevel,
146
+ cwd: worktreeCwd,
123
147
  signal: record.abortController!.signal,
124
148
  onToolActivity: (activity) => {
125
149
  if (activity.type === "end") record.toolUses++;
@@ -128,6 +152,13 @@ export class AgentManager {
128
152
  onTextDelta: options.onTextDelta,
129
153
  onSessionCreated: (session) => {
130
154
  record.session = session;
155
+ // Flush any steers that arrived before the session was ready
156
+ if (record.pendingSteers?.length) {
157
+ for (const msg of record.pendingSteers) {
158
+ session.steer(msg).catch(() => {});
159
+ }
160
+ record.pendingSteers = undefined;
161
+ }
131
162
  options.onSessionCreated?.(session);
132
163
  },
133
164
  })
@@ -139,6 +170,17 @@ export class AgentManager {
139
170
  record.result = responseText;
140
171
  record.session = session;
141
172
  record.completedAt ??= Date.now();
173
+
174
+ // Clean up worktree if used
175
+ if (record.worktree) {
176
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
177
+ record.worktreeResult = wtResult;
178
+ if (wtResult.hasChanges && wtResult.branch) {
179
+ record.result = (record.result ?? "") +
180
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
181
+ }
182
+ }
183
+
142
184
  if (options.isBackground) {
143
185
  this.runningBackground--;
144
186
  this.onComplete?.(record);
@@ -153,6 +195,15 @@ export class AgentManager {
153
195
  }
154
196
  record.error = err instanceof Error ? err.message : String(err);
155
197
  record.completedAt ??= Date.now();
198
+
199
+ // Best-effort worktree cleanup on error
200
+ if (record.worktree) {
201
+ try {
202
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
203
+ record.worktreeResult = wtResult;
204
+ } catch { /* ignore cleanup errors */ }
205
+ }
206
+
156
207
  if (options.isBackground) {
157
208
  this.runningBackground--;
158
209
  this.onComplete?.(record);
@@ -256,18 +307,30 @@ export class AgentManager {
256
307
  return true;
257
308
  }
258
309
 
310
+ /** Dispose a record's session and remove it from the map. */
311
+ private removeRecord(id: string, record: AgentRecord): void {
312
+ record.session?.dispose?.();
313
+ record.session = undefined;
314
+ this.agents.delete(id);
315
+ }
316
+
259
317
  private cleanup() {
260
318
  const cutoff = Date.now() - 10 * 60_000;
261
319
  for (const [id, record] of this.agents) {
262
320
  if (record.status === "running" || record.status === "queued") continue;
263
321
  if ((record.completedAt ?? 0) >= cutoff) continue;
322
+ this.removeRecord(id, record);
323
+ }
324
+ }
264
325
 
265
- // Dispose and clear session so memory can be reclaimed
266
- if (record.session) {
267
- record.session.dispose();
268
- record.session = undefined;
269
- }
270
- this.agents.delete(id);
326
+ /**
327
+ * Remove all completed/stopped/errored records immediately.
328
+ * Called on session start/switch so tasks from a prior session don't persist.
329
+ */
330
+ clearCompleted(): void {
331
+ for (const [id, record] of this.agents) {
332
+ if (record.status === "running" || record.status === "queued") continue;
333
+ this.removeRecord(id, record);
271
334
  }
272
335
  }
273
336
 
@@ -301,5 +364,7 @@ export class AgentManager {
301
364
  record.session?.dispose();
302
365
  }
303
366
  this.agents.clear();
367
+ // Prune any orphaned git worktrees (crash recovery)
368
+ try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
304
369
  }
305
370
  }
@@ -13,10 +13,12 @@ import {
13
13
  } from "@mariozechner/pi-coding-agent";
14
14
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
15
15
  import type { Model } from "@mariozechner/pi-ai";
16
- import { getToolsForType, getConfig, getAgentConfig } from "./agent-types.js";
17
- import { buildAgentPrompt } from "./prompts.js";
16
+ import { getToolsForType, getConfig, getAgentConfig, getMemoryTools, getReadOnlyMemoryTools } from "./agent-types.js";
17
+ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
18
18
  import { buildParentContext, extractText } from "./context.js";
19
19
  import { detectEnv } from "./env.js";
20
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
21
+ import { preloadSkills } from "./skill-loader.js";
20
22
  import type { SubagentType, ThinkingLevel } from "./types.js";
21
23
 
22
24
  /** Names of tools registered by this extension that subagents must NOT inherit. */
@@ -84,6 +86,8 @@ export interface RunOptions {
84
86
  isolated?: boolean;
85
87
  inheritContext?: boolean;
86
88
  thinkingLevel?: ThinkingLevel;
89
+ /** Override working directory (e.g. for worktree isolation). */
90
+ cwd?: string;
87
91
  /** Called on tool start/end with activity info. */
88
92
  onToolActivity?: (activity: ToolActivity) => void;
89
93
  /** Called on streaming text deltas from the assistant response. */
@@ -136,15 +140,59 @@ export async function runAgent(
136
140
  ): Promise<RunResult> {
137
141
  const config = getConfig(type);
138
142
  const agentConfig = getAgentConfig(type);
139
- const env = await detectEnv(options.pi, ctx.cwd);
143
+
144
+ // Resolve working directory: worktree override > parent cwd
145
+ const effectiveCwd = options.cwd ?? ctx.cwd;
146
+
147
+ const env = await detectEnv(options.pi, effectiveCwd);
140
148
 
141
149
  // Get parent system prompt for append-mode agents
142
150
  const parentSystemPrompt = ctx.getSystemPrompt();
143
151
 
152
+ // Build prompt extras (memory, skill preloading)
153
+ const extras: PromptExtras = {};
154
+
155
+ // Resolve extensions/skills: isolated overrides to false
156
+ const extensions = options.isolated ? false : config.extensions;
157
+ const skills = options.isolated ? false : config.skills;
158
+
159
+ // Skill preloading: when skills is string[], preload their content into prompt
160
+ if (Array.isArray(skills)) {
161
+ const loaded = preloadSkills(skills, effectiveCwd);
162
+ if (loaded.length > 0) {
163
+ extras.skillBlocks = loaded;
164
+ }
165
+ }
166
+
167
+ let tools = getToolsForType(type, effectiveCwd);
168
+
169
+ // Persistent memory: detect write capability and branch accordingly.
170
+ // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
171
+ if (agentConfig?.memory) {
172
+ const existingNames = new Set(tools.map(t => t.name));
173
+ const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
174
+ const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
175
+ const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
176
+
177
+ if (hasWriteTools) {
178
+ // Read-write memory: add any missing memory tools (read/write/edit)
179
+ const memTools = getMemoryTools(effectiveCwd, existingNames);
180
+ if (memTools.length > 0) tools = [...tools, ...memTools];
181
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
182
+ } else {
183
+ // Read-only memory: only add read tool, use read-only prompt
184
+ if (!existingNames.has("read")) {
185
+ const readTools = getReadOnlyMemoryTools(effectiveCwd, existingNames);
186
+ if (readTools.length > 0) tools = [...tools, ...readTools];
187
+ }
188
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
189
+ }
190
+ }
191
+
144
192
  // Build system prompt from agent config
145
193
  let systemPrompt: string;
146
194
  if (agentConfig) {
147
- systemPrompt = buildAgentPrompt(agentConfig, ctx.cwd, env, parentSystemPrompt);
195
+ systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
148
196
  } else {
149
197
  // Unknown type fallback: general-purpose (defensive — unreachable in practice
150
198
  // since index.ts resolves unknown types to "general-purpose" before calling runAgent)
@@ -158,20 +206,18 @@ export async function runAgent(
158
206
  inheritContext: false,
159
207
  runInBackground: false,
160
208
  isolated: false,
161
- }, ctx.cwd, env, parentSystemPrompt);
209
+ }, effectiveCwd, env, parentSystemPrompt, extras);
162
210
  }
163
211
 
164
- const tools = getToolsForType(type, ctx.cwd);
165
-
166
- // Resolve extensions/skills: isolated overrides to false
167
- const extensions = options.isolated ? false : config.extensions;
168
- const skills = options.isolated ? false : config.skills;
212
+ // When skills is string[], we've already preloaded them into the prompt.
213
+ // Still pass noSkills: true since we don't need the skill loader to load them again.
214
+ const noSkills = skills === false || Array.isArray(skills);
169
215
 
170
216
  // Load extensions/skills: true or string[] → load; false → don't
171
217
  const loader = new DefaultResourceLoader({
172
- cwd: ctx.cwd,
218
+ cwd: effectiveCwd,
173
219
  noExtensions: extensions === false,
174
- noSkills: skills === false,
220
+ noSkills,
175
221
  noPromptTemplates: true,
176
222
  noThemes: true,
177
223
  systemPromptOverride: () => systemPrompt,
@@ -187,8 +233,8 @@ export async function runAgent(
187
233
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
188
234
 
189
235
  const sessionOpts: Record<string, unknown> = {
190
- cwd: ctx.cwd,
191
- sessionManager: SessionManager.inMemory(ctx.cwd),
236
+ cwd: effectiveCwd,
237
+ sessionManager: SessionManager.inMemory(effectiveCwd),
192
238
  settingsManager: SettingsManager.create(),
193
239
  modelRegistry: ctx.modelRegistry,
194
240
  model,
@@ -202,12 +248,18 @@ export async function runAgent(
202
248
  // createAgentSession's type signature may not include thinkingLevel yet
203
249
  const { session } = await createAgentSession(sessionOpts as Parameters<typeof createAgentSession>[0]);
204
250
 
251
+ // Build disallowed tools set from agent config
252
+ const disallowedSet = agentConfig?.disallowedTools
253
+ ? new Set(agentConfig.disallowedTools)
254
+ : undefined;
255
+
205
256
  // Filter active tools: remove our own tools to prevent nesting,
206
- // and apply extension allowlist if specified
257
+ // apply extension allowlist if specified, and apply disallowedTools denylist
207
258
  if (extensions !== false) {
208
259
  const builtinToolNames = new Set(tools.map(t => t.name));
209
260
  const activeTools = session.getActiveToolNames().filter((t) => {
210
261
  if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
262
+ if (disallowedSet?.has(t)) return false;
211
263
  if (builtinToolNames.has(t)) return true;
212
264
  if (Array.isArray(extensions)) {
213
265
  return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
@@ -215,6 +267,10 @@ export async function runAgent(
215
267
  return true;
216
268
  });
217
269
  session.setActiveToolsByName(activeTools);
270
+ } else if (disallowedSet) {
271
+ // Even with extensions disabled, apply denylist to built-in tools
272
+ const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
273
+ session.setActiveToolsByName(activeTools);
218
274
  }
219
275
 
220
276
  options.onSessionCreated?.(session);
@@ -109,6 +109,32 @@ export function isValidType(type: string): boolean {
109
109
  return agents.get(key)?.enabled !== false;
110
110
  }
111
111
 
112
+ /** Tool names required for memory management. */
113
+ const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
114
+
115
+ /**
116
+ * Get the tools needed for memory management (read, write, edit).
117
+ * Only returns tools that are NOT already in the provided set.
118
+ */
119
+ export function getMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[] {
120
+ return MEMORY_TOOL_NAMES
121
+ .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
122
+ .map(n => TOOL_FACTORIES[n](cwd));
123
+ }
124
+
125
+ /** Tool names needed for read-only memory access. */
126
+ const READONLY_MEMORY_TOOL_NAMES = ["read"];
127
+
128
+ /**
129
+ * Get only the read tool for read-only memory access.
130
+ * Only returns tools that are NOT already in the provided set.
131
+ */
132
+ export function getReadOnlyMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[] {
133
+ return READONLY_MEMORY_TOOL_NAMES
134
+ .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
135
+ .map(n => TOOL_FACTORIES[n](cwd));
136
+ }
137
+
112
138
  /** Get built-in tools for a type (case-insensitive). */
113
139
  export function getToolsForType(type: string, cwd: string): AgentTool<any>[] {
114
140
  const key = resolveKey(type);
@@ -6,7 +6,7 @@ import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
6
  import { readFileSync, readdirSync, existsSync } from "node:fs";
7
7
  import { join, basename } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import type { AgentConfig, ThinkingLevel } from "./types.js";
9
+ import type { AgentConfig, ThinkingLevel, MemoryScope, IsolationMode } from "./types.js";
10
10
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
11
11
 
12
12
  /**
@@ -56,6 +56,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
56
56
  displayName: str(fm.display_name),
57
57
  description: str(fm.description) ?? name,
58
58
  builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
59
+ disallowedTools: csvListOptional(fm.disallowed_tools),
59
60
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
60
61
  skills: inheritField(fm.skills ?? fm.inherit_skills),
61
62
  model: str(fm.model),
@@ -66,6 +67,8 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
66
67
  inheritContext: fm.inherit_context === true,
67
68
  runInBackground: fm.run_in_background === true,
68
69
  isolated: fm.isolated === true,
70
+ memory: parseMemory(fm.memory),
71
+ isolation: fm.isolation === "worktree" ? "worktree" : undefined,
69
72
  enabled: fm.enabled !== false, // default true; explicitly false disables
70
73
  source,
71
74
  });
@@ -86,14 +89,40 @@ function positiveInt(val: unknown): number | undefined {
86
89
  }
87
90
 
88
91
  /**
89
- * Parse a comma-separated list field.
92
+ * Parse a raw CSV field value into items, or undefined if absent/empty/"none".
93
+ */
94
+ function parseCsvField(val: unknown): string[] | undefined {
95
+ if (val === undefined || val === null) return undefined;
96
+ const s = String(val).trim();
97
+ if (!s || s === "none") return undefined;
98
+ const items = s.split(",").map(t => t.trim()).filter(Boolean);
99
+ return items.length > 0 ? items : undefined;
100
+ }
101
+
102
+ /**
103
+ * Parse a comma-separated list field with defaults.
90
104
  * omitted → defaults; "none"/empty → []; csv → listed items.
91
105
  */
92
106
  function csvList(val: unknown, defaults: string[]): string[] {
93
107
  if (val === undefined || val === null) return defaults;
94
- const s = String(val).trim();
95
- if (!s || s === "none") return [];
96
- return s.split(",").map(t => t.trim()).filter(Boolean);
108
+ return parseCsvField(val) ?? [];
109
+ }
110
+
111
+ /**
112
+ * Parse an optional comma-separated list field.
113
+ * omitted → undefined; "none"/empty → undefined; csv → listed items.
114
+ */
115
+ function csvListOptional(val: unknown): string[] | undefined {
116
+ return parseCsvField(val);
117
+ }
118
+
119
+ /**
120
+ * Parse a memory scope field.
121
+ * omitted → undefined; "user"/"project"/"local" → MemoryScope.
122
+ */
123
+ function parseMemory(val: unknown): MemoryScope | undefined {
124
+ if (val === "user" || val === "project" || val === "local") return val;
125
+ return undefined;
97
126
  }
98
127
 
99
128
  /**