@tintinweb/pi-subagents 0.4.1 → 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 +47 -0
- package/README.md +85 -1
- package/package.json +1 -1
- package/src/agent-manager.ts +49 -3
- package/src/agent-runner.ts +71 -15
- package/src/agent-types.ts +26 -0
- package/src/custom-agents.ts +34 -5
- package/src/index.ts +73 -1
- package/src/memory.ts +165 -0
- package/src/prompts.ts +24 -2
- package/src/skill-loader.ts +79 -0
- package/src/types.ts +16 -0
- package/src/worktree.ts +162 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ 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
|
+
|
|
8
53
|
## [0.4.1] - 2026-03-11
|
|
9
54
|
|
|
10
55
|
### Fixed
|
|
@@ -197,6 +242,8 @@ Initial release.
|
|
|
197
242
|
- **Thinking level** — per-agent extended thinking control
|
|
198
243
|
- **`/agent` and `/agents` commands**
|
|
199
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
|
|
200
247
|
[0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
|
|
201
248
|
[0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
|
|
202
249
|
[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
package/src/agent-manager.ts
CHANGED
|
@@ -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,
|
|
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++;
|
|
@@ -139,6 +163,17 @@ export class AgentManager {
|
|
|
139
163
|
record.result = responseText;
|
|
140
164
|
record.session = session;
|
|
141
165
|
record.completedAt ??= Date.now();
|
|
166
|
+
|
|
167
|
+
// Clean up worktree if used
|
|
168
|
+
if (record.worktree) {
|
|
169
|
+
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
|
|
170
|
+
record.worktreeResult = wtResult;
|
|
171
|
+
if (wtResult.hasChanges && wtResult.branch) {
|
|
172
|
+
record.result = (record.result ?? "") +
|
|
173
|
+
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
142
177
|
if (options.isBackground) {
|
|
143
178
|
this.runningBackground--;
|
|
144
179
|
this.onComplete?.(record);
|
|
@@ -153,6 +188,15 @@ export class AgentManager {
|
|
|
153
188
|
}
|
|
154
189
|
record.error = err instanceof Error ? err.message : String(err);
|
|
155
190
|
record.completedAt ??= Date.now();
|
|
191
|
+
|
|
192
|
+
// Best-effort worktree cleanup on error
|
|
193
|
+
if (record.worktree) {
|
|
194
|
+
try {
|
|
195
|
+
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
|
|
196
|
+
record.worktreeResult = wtResult;
|
|
197
|
+
} catch { /* ignore cleanup errors */ }
|
|
198
|
+
}
|
|
199
|
+
|
|
156
200
|
if (options.isBackground) {
|
|
157
201
|
this.runningBackground--;
|
|
158
202
|
this.onComplete?.(record);
|
|
@@ -301,5 +345,7 @@ export class AgentManager {
|
|
|
301
345
|
record.session?.dispose();
|
|
302
346
|
}
|
|
303
347
|
this.agents.clear();
|
|
348
|
+
// Prune any orphaned git worktrees (crash recovery)
|
|
349
|
+
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
|
|
304
350
|
}
|
|
305
351
|
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
},
|
|
209
|
+
}, effectiveCwd, env, parentSystemPrompt, extras);
|
|
162
210
|
}
|
|
163
211
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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:
|
|
218
|
+
cwd: effectiveCwd,
|
|
173
219
|
noExtensions: extensions === false,
|
|
174
|
-
noSkills
|
|
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:
|
|
191
|
-
sessionManager: SessionManager.inMemory(
|
|
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
|
-
//
|
|
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);
|
package/src/agent-types.ts
CHANGED
|
@@ -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);
|
package/src/custom-agents.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
/**
|
package/src/index.ts
CHANGED
|
@@ -204,8 +204,44 @@ export default function (pi: ExtensionAPI) {
|
|
|
204
204
|
30_000,
|
|
205
205
|
);
|
|
206
206
|
|
|
207
|
+
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
208
|
+
function buildEventData(record: AgentRecord) {
|
|
209
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
210
|
+
let tokens: { input: number; output: number; total: number } | undefined;
|
|
211
|
+
try {
|
|
212
|
+
if (record.session) {
|
|
213
|
+
const stats = record.session.getSessionStats();
|
|
214
|
+
tokens = {
|
|
215
|
+
input: stats.tokens?.input ?? 0,
|
|
216
|
+
output: stats.tokens?.output ?? 0,
|
|
217
|
+
total: stats.tokens?.total ?? 0,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
} catch { /* session stats unavailable */ }
|
|
221
|
+
return {
|
|
222
|
+
id: record.id,
|
|
223
|
+
type: record.type,
|
|
224
|
+
description: record.description,
|
|
225
|
+
result: record.result,
|
|
226
|
+
error: record.error,
|
|
227
|
+
status: record.status,
|
|
228
|
+
toolUses: record.toolUses,
|
|
229
|
+
durationMs,
|
|
230
|
+
tokens,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
207
234
|
// Background completion: route through group join or send individual nudge
|
|
208
235
|
const manager = new AgentManager((record) => {
|
|
236
|
+
// Emit lifecycle event based on terminal status
|
|
237
|
+
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
238
|
+
const eventData = buildEventData(record);
|
|
239
|
+
if (isError) {
|
|
240
|
+
pi.events.emit("subagents:failed", eventData);
|
|
241
|
+
} else {
|
|
242
|
+
pi.events.emit("subagents:completed", eventData);
|
|
243
|
+
}
|
|
244
|
+
|
|
209
245
|
// Skip notification if result was already consumed via get_subagent_result
|
|
210
246
|
if (record.resultConsumed) {
|
|
211
247
|
agentActivity.delete(record.id);
|
|
@@ -228,6 +264,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
228
264
|
// 'held' → do nothing, group will fire later
|
|
229
265
|
// 'delivered' → group callback already fired
|
|
230
266
|
widget.update();
|
|
267
|
+
}, undefined, (record) => {
|
|
268
|
+
// Emit started event when agent transitions to running (including from queue)
|
|
269
|
+
pi.events.emit("subagents:started", {
|
|
270
|
+
id: record.id,
|
|
271
|
+
type: record.type,
|
|
272
|
+
description: record.description,
|
|
273
|
+
});
|
|
231
274
|
});
|
|
232
275
|
|
|
233
276
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
@@ -236,6 +279,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
279
|
(globalThis as any)[MANAGER_KEY] = {
|
|
237
280
|
waitForAll: () => manager.waitForAll(),
|
|
238
281
|
hasRunning: () => manager.hasRunning(),
|
|
282
|
+
spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
|
|
283
|
+
manager.spawn(piRef, ctx, type, prompt, options),
|
|
284
|
+
getRecord: (id: string) => manager.getRecord(id),
|
|
239
285
|
};
|
|
240
286
|
|
|
241
287
|
// Wait for all subagents on shutdown, then dispose the manager
|
|
@@ -362,6 +408,7 @@ Guidelines:
|
|
|
362
408
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
363
409
|
- Use thinking to control extended thinking level.
|
|
364
410
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
411
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
|
|
365
412
|
- Use join_mode to control how background completion notifications are delivered. By default (smart), 2+ background agents spawned in the same turn are grouped into a single notification. Use "async" for individual notifications or "group" to force grouping.`,
|
|
366
413
|
parameters: Type.Object({
|
|
367
414
|
prompt: Type.String({
|
|
@@ -410,6 +457,11 @@ Guidelines:
|
|
|
410
457
|
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
411
458
|
}),
|
|
412
459
|
),
|
|
460
|
+
isolation: Type.Optional(
|
|
461
|
+
Type.Literal("worktree", {
|
|
462
|
+
description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
|
|
463
|
+
}),
|
|
464
|
+
),
|
|
413
465
|
join_mode: Type.Optional(
|
|
414
466
|
Type.Union([
|
|
415
467
|
Type.Literal("async"),
|
|
@@ -544,6 +596,7 @@ Guidelines:
|
|
|
544
596
|
const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
|
|
545
597
|
const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
|
|
546
598
|
const isolated = params.isolated ?? customConfig?.isolated ?? false;
|
|
599
|
+
const isolation = params.isolation ?? customConfig?.isolation;
|
|
547
600
|
|
|
548
601
|
// Build display tags for non-default config
|
|
549
602
|
const parentModelId = ctx.model?.id;
|
|
@@ -556,6 +609,7 @@ Guidelines:
|
|
|
556
609
|
if (modeLabel) agentTags.push(modeLabel);
|
|
557
610
|
if (thinking) agentTags.push(`thinking: ${thinking}`);
|
|
558
611
|
if (isolated) agentTags.push("isolated");
|
|
612
|
+
if (isolation === "worktree") agentTags.push("worktree");
|
|
559
613
|
// Shared base fields for all AgentDetails in this call
|
|
560
614
|
const detailBase = {
|
|
561
615
|
displayName,
|
|
@@ -596,6 +650,7 @@ Guidelines:
|
|
|
596
650
|
inheritContext,
|
|
597
651
|
thinkingLevel: thinking,
|
|
598
652
|
isBackground: true,
|
|
653
|
+
isolation,
|
|
599
654
|
...bgCallbacks,
|
|
600
655
|
});
|
|
601
656
|
|
|
@@ -618,6 +673,15 @@ Guidelines:
|
|
|
618
673
|
agentActivity.set(id, bgState);
|
|
619
674
|
widget.ensureTimer();
|
|
620
675
|
widget.update();
|
|
676
|
+
|
|
677
|
+
// Emit created event
|
|
678
|
+
pi.events.emit("subagents:created", {
|
|
679
|
+
id,
|
|
680
|
+
type: subagentType,
|
|
681
|
+
description: params.description,
|
|
682
|
+
isBackground: true,
|
|
683
|
+
});
|
|
684
|
+
|
|
621
685
|
const isQueued = record?.status === "queued";
|
|
622
686
|
return textResult(
|
|
623
687
|
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
@@ -684,6 +748,7 @@ Guidelines:
|
|
|
684
748
|
isolated,
|
|
685
749
|
inheritContext,
|
|
686
750
|
thinkingLevel: thinking,
|
|
751
|
+
isolation,
|
|
687
752
|
...fgCallbacks,
|
|
688
753
|
});
|
|
689
754
|
|
|
@@ -817,6 +882,7 @@ Guidelines:
|
|
|
817
882
|
|
|
818
883
|
try {
|
|
819
884
|
await steerAgent(record.session, params.message);
|
|
885
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
820
886
|
return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
|
|
821
887
|
} catch (err) {
|
|
822
888
|
return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1092,9 +1158,12 @@ Guidelines:
|
|
|
1092
1158
|
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1093
1159
|
if (cfg.skills === false) fmFields.push("skills: false");
|
|
1094
1160
|
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1161
|
+
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
1095
1162
|
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
1096
1163
|
if (cfg.runInBackground) fmFields.push("run_in_background: true");
|
|
1097
1164
|
if (cfg.isolated) fmFields.push("isolated: true");
|
|
1165
|
+
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
1166
|
+
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
1098
1167
|
|
|
1099
1168
|
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
1100
1169
|
|
|
@@ -1214,10 +1283,13 @@ thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit
|
|
|
1214
1283
|
max_turns: <optional max agentic turns, default 50. Omit for default>
|
|
1215
1284
|
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
1216
1285
|
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
1217
|
-
skills: <true (inherit all), false (none). Default: true>
|
|
1286
|
+
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
1287
|
+
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
1218
1288
|
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
1219
1289
|
run_in_background: <true to run in background by default. Default: false>
|
|
1220
1290
|
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
1291
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
1292
|
+
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
1221
1293
|
---
|
|
1222
1294
|
|
|
1223
1295
|
<system prompt body — instructions for the agent>
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
|
|
3
|
+
*
|
|
4
|
+
* Memory scopes:
|
|
5
|
+
* - "user" → ~/.pi/agent-memory/{agent-name}/
|
|
6
|
+
* - "project" → .pi/agent-memory/{agent-name}/
|
|
7
|
+
* - "local" → .pi/agent-memory-local/{agent-name}/
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, mkdirSync, lstatSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import type { MemoryScope } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Maximum lines to read from MEMORY.md */
|
|
16
|
+
const MAX_MEMORY_LINES = 200;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns true if a name contains characters not allowed in agent/skill names.
|
|
20
|
+
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
|
|
21
|
+
*/
|
|
22
|
+
export function isUnsafeName(name: string): boolean {
|
|
23
|
+
if (!name || name.length > 128) return true;
|
|
24
|
+
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if the given path is a symlink (defense against symlink attacks).
|
|
29
|
+
*/
|
|
30
|
+
export function isSymlink(filePath: string): boolean {
|
|
31
|
+
try {
|
|
32
|
+
return lstatSync(filePath).isSymbolicLink();
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Safely read a file, rejecting symlinks.
|
|
40
|
+
* Returns undefined if the file doesn't exist, is a symlink, or can't be read.
|
|
41
|
+
*/
|
|
42
|
+
export function safeReadFile(filePath: string): string | undefined {
|
|
43
|
+
if (!existsSync(filePath)) return undefined;
|
|
44
|
+
if (isSymlink(filePath)) return undefined;
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(filePath, "utf-8");
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the memory directory path for a given agent + scope + cwd.
|
|
54
|
+
* Throws if agentName contains path traversal characters.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
57
|
+
if (isUnsafeName(agentName)) {
|
|
58
|
+
throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
|
|
59
|
+
}
|
|
60
|
+
switch (scope) {
|
|
61
|
+
case "user":
|
|
62
|
+
return join(homedir(), ".pi", "agent-memory", agentName);
|
|
63
|
+
case "project":
|
|
64
|
+
return join(cwd, ".pi", "agent-memory", agentName);
|
|
65
|
+
case "local":
|
|
66
|
+
return join(cwd, ".pi", "agent-memory-local", agentName);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ensure the memory directory exists, creating it if needed.
|
|
72
|
+
* Refuses to create directories if any component in the path is a symlink
|
|
73
|
+
* to prevent symlink-based directory traversal attacks.
|
|
74
|
+
*/
|
|
75
|
+
export function ensureMemoryDir(memoryDir: string): void {
|
|
76
|
+
// If the directory already exists, verify it's not a symlink
|
|
77
|
+
if (existsSync(memoryDir)) {
|
|
78
|
+
if (isSymlink(memoryDir)) {
|
|
79
|
+
throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the first N lines of MEMORY.md from the memory directory, if it exists.
|
|
88
|
+
* Returns undefined if no MEMORY.md exists or if the path is a symlink.
|
|
89
|
+
*/
|
|
90
|
+
export function readMemoryIndex(memoryDir: string): string | undefined {
|
|
91
|
+
// Reject symlinked memory directories
|
|
92
|
+
if (isSymlink(memoryDir)) return undefined;
|
|
93
|
+
|
|
94
|
+
const memoryFile = join(memoryDir, "MEMORY.md");
|
|
95
|
+
const content = safeReadFile(memoryFile);
|
|
96
|
+
if (content === undefined) return undefined;
|
|
97
|
+
|
|
98
|
+
const lines = content.split("\n");
|
|
99
|
+
if (lines.length > MAX_MEMORY_LINES) {
|
|
100
|
+
return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)";
|
|
101
|
+
}
|
|
102
|
+
return content;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the memory block to inject into the agent's system prompt.
|
|
107
|
+
* Also ensures the memory directory exists (creates it if needed).
|
|
108
|
+
*/
|
|
109
|
+
export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
110
|
+
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
|
111
|
+
// Create the memory directory so the agent can immediately write to it
|
|
112
|
+
ensureMemoryDir(memoryDir);
|
|
113
|
+
|
|
114
|
+
const existingMemory = readMemoryIndex(memoryDir);
|
|
115
|
+
|
|
116
|
+
const header = `# Agent Memory
|
|
117
|
+
|
|
118
|
+
You have a persistent memory directory at: ${memoryDir}/
|
|
119
|
+
Memory scope: ${scope}
|
|
120
|
+
|
|
121
|
+
This memory persists across sessions. Use it to build up knowledge over time.`;
|
|
122
|
+
|
|
123
|
+
const memoryContent = existingMemory
|
|
124
|
+
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
|
125
|
+
: `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`;
|
|
126
|
+
|
|
127
|
+
const instructions = `
|
|
128
|
+
|
|
129
|
+
## Memory Instructions
|
|
130
|
+
- MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated.
|
|
131
|
+
- Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md.
|
|
132
|
+
- Each memory file should use this frontmatter format:
|
|
133
|
+
\`\`\`markdown
|
|
134
|
+
---
|
|
135
|
+
name: <memory name>
|
|
136
|
+
description: <one-line description>
|
|
137
|
+
type: <user|feedback|project|reference>
|
|
138
|
+
---
|
|
139
|
+
<memory content>
|
|
140
|
+
\`\`\`
|
|
141
|
+
- Update or remove memories that become outdated. Check for existing memories before creating duplicates.
|
|
142
|
+
- You have Read, Write, and Edit tools available for managing memory files.`;
|
|
143
|
+
|
|
144
|
+
return header + memoryContent + instructions;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a read-only memory block for agents that lack write/edit tools.
|
|
149
|
+
* Does NOT create the memory directory — agents can only consume existing memory.
|
|
150
|
+
*/
|
|
151
|
+
export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
152
|
+
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
|
153
|
+
const existingMemory = readMemoryIndex(memoryDir);
|
|
154
|
+
|
|
155
|
+
const header = `# Agent Memory (read-only)
|
|
156
|
+
|
|
157
|
+
Memory scope: ${scope}
|
|
158
|
+
You have read-only access to memory. You can reference existing memories but cannot create or modify them.`;
|
|
159
|
+
|
|
160
|
+
const memoryContent = existingMemory
|
|
161
|
+
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
|
162
|
+
: `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`;
|
|
163
|
+
|
|
164
|
+
return header + memoryContent;
|
|
165
|
+
}
|
package/src/prompts.ts
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import type { AgentConfig, EnvInfo } from "./types.js";
|
|
6
6
|
|
|
7
|
+
/** Extra sections to inject into the system prompt (memory, skills, etc.). */
|
|
8
|
+
export interface PromptExtras {
|
|
9
|
+
/** Persistent memory content to inject (first 200 lines of MEMORY.md + instructions). */
|
|
10
|
+
memoryBlock?: string;
|
|
11
|
+
/** Preloaded skill contents to inject. */
|
|
12
|
+
skillBlocks?: { name: string; content: string }[];
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
/**
|
|
8
16
|
* Build the system prompt for an agent from its config.
|
|
9
17
|
*
|
|
@@ -12,18 +20,32 @@ import type { AgentConfig, EnvInfo } from "./types.js";
|
|
|
12
20
|
* - "append" with empty systemPrompt: pure parent clone
|
|
13
21
|
*
|
|
14
22
|
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
23
|
+
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
|
15
24
|
*/
|
|
16
25
|
export function buildAgentPrompt(
|
|
17
26
|
config: AgentConfig,
|
|
18
27
|
cwd: string,
|
|
19
28
|
env: EnvInfo,
|
|
20
29
|
parentSystemPrompt?: string,
|
|
30
|
+
extras?: PromptExtras,
|
|
21
31
|
): string {
|
|
22
32
|
const envBlock = `# Environment
|
|
23
33
|
Working directory: ${cwd}
|
|
24
34
|
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
25
35
|
Platform: ${env.platform}`;
|
|
26
36
|
|
|
37
|
+
// Build optional extras suffix
|
|
38
|
+
const extraSections: string[] = [];
|
|
39
|
+
if (extras?.memoryBlock) {
|
|
40
|
+
extraSections.push(extras.memoryBlock);
|
|
41
|
+
}
|
|
42
|
+
if (extras?.skillBlocks?.length) {
|
|
43
|
+
for (const skill of extras.skillBlocks) {
|
|
44
|
+
extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
|
|
48
|
+
|
|
27
49
|
if (config.promptMode === "append") {
|
|
28
50
|
const identity = parentSystemPrompt || genericBase;
|
|
29
51
|
|
|
@@ -44,7 +66,7 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
44
66
|
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
45
67
|
: "";
|
|
46
68
|
|
|
47
|
-
return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection;
|
|
69
|
+
return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
|
|
48
70
|
}
|
|
49
71
|
|
|
50
72
|
// "replace" mode — env header + the config's full system prompt
|
|
@@ -53,7 +75,7 @@ You have been invoked to handle a specific task autonomously.
|
|
|
53
75
|
|
|
54
76
|
${envBlock}`;
|
|
55
77
|
|
|
56
|
-
return replaceHeader + "\n\n" + config.systemPrompt;
|
|
78
|
+
return replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-loader.ts — Preload specific skill files and inject their content into the system prompt.
|
|
3
|
+
*
|
|
4
|
+
* When skills is a string[], reads each named skill from .pi/skills/ or ~/.pi/skills/
|
|
5
|
+
* and returns their content for injection into the agent's system prompt.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { isUnsafeName, safeReadFile } from "./memory.js";
|
|
11
|
+
|
|
12
|
+
export interface PreloadedSkill {
|
|
13
|
+
name: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attempt to load named skills from project and global skill directories.
|
|
19
|
+
* Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
|
|
20
|
+
*
|
|
21
|
+
* @param skillNames List of skill names to preload.
|
|
22
|
+
* @param cwd Working directory for project-level skills.
|
|
23
|
+
* @returns Array of loaded skills (missing skills are skipped with a warning comment).
|
|
24
|
+
*/
|
|
25
|
+
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
|
|
26
|
+
const results: PreloadedSkill[] = [];
|
|
27
|
+
|
|
28
|
+
for (const name of skillNames) {
|
|
29
|
+
// Unlike memory (which throws on unsafe names because it's part of agent setup),
|
|
30
|
+
// skills are optional — skip gracefully to avoid blocking agent startup.
|
|
31
|
+
if (isUnsafeName(name)) {
|
|
32
|
+
results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const content = findAndReadSkill(name, cwd);
|
|
36
|
+
if (content !== undefined) {
|
|
37
|
+
results.push({ name, content });
|
|
38
|
+
} else {
|
|
39
|
+
// Include a note about missing skills so the agent knows it was requested but not found
|
|
40
|
+
results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Search for a skill file in project and global directories.
|
|
49
|
+
* Project-level takes priority over global.
|
|
50
|
+
*/
|
|
51
|
+
function findAndReadSkill(name: string, cwd: string): string | undefined {
|
|
52
|
+
const projectDir = join(cwd, ".pi", "skills");
|
|
53
|
+
const globalDir = join(homedir(), ".pi", "skills");
|
|
54
|
+
|
|
55
|
+
// Try project first, then global
|
|
56
|
+
for (const dir of [projectDir, globalDir]) {
|
|
57
|
+
const content = tryReadSkillFile(dir, name);
|
|
58
|
+
if (content !== undefined) return content;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Try to read a skill file from a directory.
|
|
66
|
+
* Tries extensions in order: .md, .txt, (no extension)
|
|
67
|
+
*/
|
|
68
|
+
function tryReadSkillFile(dir: string, name: string): string | undefined {
|
|
69
|
+
const extensions = [".md", ".txt", ""];
|
|
70
|
+
|
|
71
|
+
for (const ext of extensions) {
|
|
72
|
+
const path = join(dir, name + ext);
|
|
73
|
+
// safeReadFile rejects symlinks to prevent reading arbitrary files
|
|
74
|
+
const content = safeReadFile(path);
|
|
75
|
+
if (content !== undefined) return content.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -13,12 +13,20 @@ export type SubagentType = string;
|
|
|
13
13
|
/** Names of the three embedded default agents. */
|
|
14
14
|
export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const;
|
|
15
15
|
|
|
16
|
+
/** Memory scope for persistent agent memory. */
|
|
17
|
+
export type MemoryScope = "user" | "project" | "local";
|
|
18
|
+
|
|
19
|
+
/** Isolation mode for agent execution. */
|
|
20
|
+
export type IsolationMode = "worktree";
|
|
21
|
+
|
|
16
22
|
/** Unified agent configuration — used for both default and user-defined agents. */
|
|
17
23
|
export interface AgentConfig {
|
|
18
24
|
name: string;
|
|
19
25
|
displayName?: string;
|
|
20
26
|
description: string;
|
|
21
27
|
builtinToolNames?: string[];
|
|
28
|
+
/** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
|
|
29
|
+
disallowedTools?: string[];
|
|
22
30
|
/** true = inherit all, string[] = only listed, false = none */
|
|
23
31
|
extensions: true | string[] | false;
|
|
24
32
|
/** true = inherit all, string[] = only listed, false = none */
|
|
@@ -34,6 +42,10 @@ export interface AgentConfig {
|
|
|
34
42
|
runInBackground: boolean;
|
|
35
43
|
/** Default for spawn: no extension tools */
|
|
36
44
|
isolated: boolean;
|
|
45
|
+
/** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
|
|
46
|
+
memory?: MemoryScope;
|
|
47
|
+
/** Isolation mode — "worktree" runs the agent in a temporary git worktree */
|
|
48
|
+
isolation?: IsolationMode;
|
|
37
49
|
/** true = this is an embedded default agent (informational) */
|
|
38
50
|
isDefault?: boolean;
|
|
39
51
|
/** false = agent is hidden from the registry */
|
|
@@ -61,6 +73,10 @@ export interface AgentRecord {
|
|
|
61
73
|
joinMode?: JoinMode;
|
|
62
74
|
/** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
|
|
63
75
|
resultConsumed?: boolean;
|
|
76
|
+
/** Worktree info if the agent is running in an isolated worktree. */
|
|
77
|
+
worktree?: { path: string; branch: string };
|
|
78
|
+
/** Worktree cleanup result after agent completion. */
|
|
79
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
export interface EnvInfo {
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree.ts — Git worktree isolation for agents.
|
|
3
|
+
*
|
|
4
|
+
* Creates a temporary git worktree so the agent works on an isolated copy of the repo.
|
|
5
|
+
* On completion, if no changes were made, the worktree is cleaned up.
|
|
6
|
+
* If changes exist, a branch is created and returned in the result.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
export interface WorktreeInfo {
|
|
16
|
+
/** Absolute path to the worktree directory. */
|
|
17
|
+
path: string;
|
|
18
|
+
/** Branch name created for this worktree (if changes exist). */
|
|
19
|
+
branch: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorktreeCleanupResult {
|
|
23
|
+
/** Whether changes were found in the worktree. */
|
|
24
|
+
hasChanges: boolean;
|
|
25
|
+
/** Branch name if changes were committed. */
|
|
26
|
+
branch?: string;
|
|
27
|
+
/** Worktree path if it was kept. */
|
|
28
|
+
path?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a temporary git worktree for an agent.
|
|
33
|
+
* Returns the worktree path, or undefined if not in a git repo.
|
|
34
|
+
*/
|
|
35
|
+
export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
|
|
36
|
+
// Verify we're in a git repo with at least one commit (HEAD must exist)
|
|
37
|
+
try {
|
|
38
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
39
|
+
execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const branch = `pi-agent-${agentId}`;
|
|
45
|
+
const suffix = randomUUID().slice(0, 8);
|
|
46
|
+
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// Create detached worktree at HEAD
|
|
50
|
+
execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
|
|
51
|
+
cwd,
|
|
52
|
+
stdio: "pipe",
|
|
53
|
+
timeout: 30000,
|
|
54
|
+
});
|
|
55
|
+
return { path: worktreePath, branch };
|
|
56
|
+
} catch {
|
|
57
|
+
// If worktree creation fails, return undefined (agent runs in normal cwd)
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Clean up a worktree after agent completion.
|
|
64
|
+
* - If no changes: remove worktree entirely.
|
|
65
|
+
* - If changes exist: create a branch, commit changes, return branch info.
|
|
66
|
+
*/
|
|
67
|
+
export function cleanupWorktree(
|
|
68
|
+
cwd: string,
|
|
69
|
+
worktree: WorktreeInfo,
|
|
70
|
+
agentDescription: string,
|
|
71
|
+
): WorktreeCleanupResult {
|
|
72
|
+
if (!existsSync(worktree.path)) {
|
|
73
|
+
return { hasChanges: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Check for uncommitted changes in the worktree
|
|
78
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
79
|
+
cwd: worktree.path,
|
|
80
|
+
stdio: "pipe",
|
|
81
|
+
timeout: 10000,
|
|
82
|
+
}).toString().trim();
|
|
83
|
+
|
|
84
|
+
if (!status) {
|
|
85
|
+
// No changes — remove worktree
|
|
86
|
+
removeWorktree(cwd, worktree.path);
|
|
87
|
+
return { hasChanges: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Changes exist — stage, commit, and create a branch
|
|
91
|
+
execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
|
|
92
|
+
// Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
|
|
93
|
+
const safeDesc = agentDescription.slice(0, 200);
|
|
94
|
+
const commitMsg = `pi-agent: ${safeDesc}`;
|
|
95
|
+
execFileSync("git", ["commit", "-m", commitMsg], {
|
|
96
|
+
cwd: worktree.path,
|
|
97
|
+
stdio: "pipe",
|
|
98
|
+
timeout: 10000,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Create a branch pointing to the worktree's HEAD.
|
|
102
|
+
// If the branch already exists, append a suffix to avoid overwriting previous work.
|
|
103
|
+
let branchName = worktree.branch;
|
|
104
|
+
try {
|
|
105
|
+
execFileSync("git", ["branch", branchName], {
|
|
106
|
+
cwd: worktree.path,
|
|
107
|
+
stdio: "pipe",
|
|
108
|
+
timeout: 5000,
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
// Branch already exists — use a unique suffix
|
|
112
|
+
branchName = `${worktree.branch}-${Date.now()}`;
|
|
113
|
+
execFileSync("git", ["branch", branchName], {
|
|
114
|
+
cwd: worktree.path,
|
|
115
|
+
stdio: "pipe",
|
|
116
|
+
timeout: 5000,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// Update branch name in worktree info for the caller
|
|
120
|
+
worktree.branch = branchName;
|
|
121
|
+
|
|
122
|
+
// Remove the worktree (branch persists in main repo)
|
|
123
|
+
removeWorktree(cwd, worktree.path);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
hasChanges: true,
|
|
127
|
+
branch: worktree.branch,
|
|
128
|
+
path: worktree.path,
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
// Best effort cleanup on error
|
|
132
|
+
try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ }
|
|
133
|
+
return { hasChanges: false };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Force-remove a worktree.
|
|
139
|
+
*/
|
|
140
|
+
function removeWorktree(cwd: string, worktreePath: string): void {
|
|
141
|
+
try {
|
|
142
|
+
execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
|
|
143
|
+
cwd,
|
|
144
|
+
stdio: "pipe",
|
|
145
|
+
timeout: 10000,
|
|
146
|
+
});
|
|
147
|
+
} catch {
|
|
148
|
+
// If git worktree remove fails, try pruning
|
|
149
|
+
try {
|
|
150
|
+
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
151
|
+
} catch { /* ignore */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Prune any orphaned worktrees (crash recovery).
|
|
157
|
+
*/
|
|
158
|
+
export function pruneWorktrees(cwd: string): void {
|
|
159
|
+
try {
|
|
160
|
+
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
161
|
+
} catch { /* ignore */ }
|
|
162
|
+
}
|