@tintinweb/pi-subagents 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +11 -11
  4. package/biome.json +26 -0
  5. package/dist/agent-manager.d.ts +18 -4
  6. package/dist/agent-manager.js +111 -9
  7. package/dist/agent-runner.d.ts +10 -6
  8. package/dist/agent-runner.js +80 -26
  9. package/dist/agent-types.d.ts +10 -0
  10. package/dist/agent-types.js +23 -1
  11. package/dist/cross-extension-rpc.d.ts +30 -0
  12. package/dist/cross-extension-rpc.js +33 -0
  13. package/dist/custom-agents.js +36 -8
  14. package/dist/index.js +335 -66
  15. package/dist/memory.d.ts +49 -0
  16. package/dist/memory.js +151 -0
  17. package/dist/output-file.d.ts +17 -0
  18. package/dist/output-file.js +66 -0
  19. package/dist/prompts.d.ts +12 -1
  20. package/dist/prompts.js +15 -3
  21. package/dist/skill-loader.d.ts +19 -0
  22. package/dist/skill-loader.js +67 -0
  23. package/dist/types.d.ts +45 -1
  24. package/dist/ui/agent-widget.d.ts +21 -0
  25. package/dist/ui/agent-widget.js +205 -127
  26. package/dist/ui/conversation-viewer.d.ts +2 -2
  27. package/dist/ui/conversation-viewer.js +2 -2
  28. package/dist/ui/conversation-viewer.test.d.ts +1 -0
  29. package/dist/ui/conversation-viewer.test.js +254 -0
  30. package/dist/worktree.d.ts +36 -0
  31. package/dist/worktree.js +139 -0
  32. package/package.json +7 -2
  33. package/src/agent-manager.ts +7 -5
  34. package/src/agent-runner.ts +24 -19
  35. package/src/agent-types.ts +5 -5
  36. package/src/custom-agents.ts +4 -4
  37. package/src/index.ts +54 -33
  38. package/src/memory.ts +2 -2
  39. package/src/output-file.ts +1 -1
  40. package/src/skill-loader.ts +1 -1
  41. package/src/types.ts +3 -1
  42. package/src/ui/agent-widget.ts +18 -2
  43. package/src/ui/conversation-viewer.ts +4 -4
  44. package/src/worktree.ts +2 -2
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-node@v4
15
+ with:
16
+ node-version: 20
17
+ cache: npm
18
+ - run: npm ci
19
+ - run: npm run lint
20
+ - run: npm run typecheck
21
+ - run: npm run test
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.11] - 2026-03-18
9
+
10
+ ### Fixed
11
+ - **Stale dist in published package** — added `prepublishOnly` hook to build fresh `dist/` on every `npm publish`.
12
+
13
+ ## [0.4.10] - 2026-03-18
14
+
15
+ ### Changed
16
+ - **Default max turns is now unlimited** — subagents no longer have a 50-turn default cap. The default is unlimited (no turn limit), matching Claude Code's main loop behavior. Users can still set explicit limits per-agent via `max_turns` frontmatter or the Agent tool parameter, or globally via `/agents` → Settings (`0` = unlimited).
17
+ - **Live turn counter** — all agents now show a live turn count in the widget, inline result, and completion notification. With a turn limit: `⟳5≤30` (5 of 30 turns). Without: `⟳5`. Updates in real time as turns progress.
18
+
19
+ ### Added
20
+ - **Biome linting** — added [Biome](https://biomejs.dev/) for correctness linting (unused imports, suspicious patterns). Style rules disabled. Run `npm run lint` to check, `npm run lint:fix` to auto-fix.
21
+ - **CI workflow** — GitHub Actions runs lint, typecheck, and tests on push to master and PRs.
22
+
23
+ ### Fixed
24
+ - **Env test CI failure** — `detectEnv` test assumed a branch name exists, but CI checks out detached HEAD. Split into separate tests for repo detection and branch detection with a controlled temp repo.
25
+
8
26
  ## [0.4.9] - 2026-03-18
9
27
 
10
28
  ### Fixed
package/README.md CHANGED
@@ -64,9 +64,9 @@ The extension renders a persistent widget above the editor showing all active ag
64
64
 
65
65
  ```
66
66
  ● Agents
67
- ├─ ⠹ Agent Refactor auth module · 5 tool uses · 33.8k token · 12.3s
67
+ ├─ ⠹ Agent Refactor auth module · 5≤30 · 5 tool uses · 33.8k token · 12.3s
68
68
  │ ⎿ editing 2 files…
69
- ├─ ⠹ Explore Find auth files · 3 tool uses · 12.4k token · 4.1s
69
+ ├─ ⠹ Explore Find auth files · 3 · 3 tool uses · 12.4k token · 4.1s
70
70
  │ ⎿ searching…
71
71
  └─ 2 queued
72
72
  ```
@@ -75,12 +75,12 @@ Individual agent results render Claude Code-style in the conversation:
75
75
 
76
76
  | State | Example |
77
77
  |-------|---------|
78
- | **Running** | `⠹ 3 tool uses · 12.4k token` / `⎿ searching, reading 3 files…` |
79
- | **Completed** | `✓ 5 tool uses · 33.8k token · 12.3s` / `⎿ Done` |
80
- | **Wrapped up** | `✓ 50 tool uses · 89.1k token · 45.2s` / `⎿ Wrapped up (turn limit)` |
81
- | **Stopped** | `■ 3 tool uses · 12.4k token` / `⎿ Stopped` |
82
- | **Error** | `✗ 3 tool uses · 12.4k token` / `⎿ Error: timeout` |
83
- | **Aborted** | `✗ 55 tool uses · 102.3k token` / `⎿ Aborted (max turns exceeded)` |
78
+ | **Running** | `⠹ 3≤30 · 3 tool uses · 12.4k token` / `⎿ searching, reading 3 files…` |
79
+ | **Completed** | `✓ ⟳8 · 5 tool uses · 33.8k token · 12.3s` / `⎿ Done` |
80
+ | **Wrapped up** | `✓ 50≤50 · 50 tool uses · 89.1k token · 45.2s` / `⎿ Wrapped up (turn limit)` |
81
+ | **Stopped** | `■ 3 · 3 tool uses · 12.4k token` / `⎿ Stopped` |
82
+ | **Error** | `✗ 3 · 3 tool uses · 12.4k token` / `⎿ Error: timeout` |
83
+ | **Aborted** | `✗ 55≤50 · 55 tool uses · 102.3k token` / `⎿ Aborted (max turns exceeded)` |
84
84
 
85
85
  Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
86
86
 
@@ -88,7 +88,7 @@ Background agent completion notifications render as styled boxes:
88
88
 
89
89
  ```
90
90
  ✓ Find auth files completed
91
- 3 tool uses · 12.4k token · 4.1s
91
+ 3 · 3 tool uses · 12.4k token · 4.1s
92
92
  ⎿ Found 5 files related to authentication...
93
93
  transcript: .pi/output/agent-abc123.jsonl
94
94
  ```
@@ -162,7 +162,7 @@ All fields are optional — sensible defaults for everything.
162
162
  | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
163
163
  | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
164
164
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh |
165
- | `max_turns` | 50 | Max agentic turns before graceful shutdown |
165
+ | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
166
166
  | `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) |
167
167
  | `inherit_context` | `false` | Fork parent conversation into agent |
168
168
  | `run_in_background` | `false` | Run in background by default |
@@ -185,7 +185,7 @@ Launch a sub-agent.
185
185
  | `subagent_type` | string | yes | Agent type (built-in or custom) |
186
186
  | `model` | string | no | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
187
187
  | `thinking` | string | no | Thinking level: off, minimal, low, medium, high, xhigh |
188
- | `max_turns` | number | no | Max agentic turns (default: 50) |
188
+ | `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
189
189
  | `run_in_background` | boolean | no | Run without blocking |
190
190
  | `resume` | string | no | Agent ID to resume a previous session |
191
191
  | `isolated` | boolean | no | No extension/MCP tools |
package/biome.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
3
+ "linter": {
4
+ "enabled": true,
5
+ "rules": {
6
+ "recommended": true,
7
+ "style": {
8
+ "recommended": false
9
+ },
10
+ "suspicious": {
11
+ "noExplicitAny": "off",
12
+ "noControlCharactersInRegex": "off",
13
+ "noEmptyInterface": "off"
14
+ }
15
+ }
16
+ },
17
+ "formatter": {
18
+ "enabled": false
19
+ },
20
+ "files": {
21
+ "includes": [
22
+ "src/**/*.ts",
23
+ "test/**/*.ts"
24
+ ]
25
+ }
26
+ }
@@ -5,12 +5,12 @@
5
5
  * Excess agents are queued and auto-started as running agents complete.
6
6
  * Foreground agents bypass the queue (they block the parent anyway).
7
7
  */
8
- import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
8
  import type { Model } from "@mariozechner/pi-ai";
10
- import type { AgentSession } from "@mariozechner/pi-coding-agent";
9
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
10
  import { type ToolActivity } from "./agent-runner.js";
12
- import type { SubagentType, AgentRecord, ThinkingLevel } from "./types.js";
11
+ import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
13
12
  export type OnAgentComplete = (record: AgentRecord) => void;
13
+ export type OnAgentStart = (record: AgentRecord) => void;
14
14
  interface SpawnOptions {
15
15
  description: string;
16
16
  model?: Model<any>;
@@ -19,23 +19,28 @@ interface SpawnOptions {
19
19
  inheritContext?: boolean;
20
20
  thinkingLevel?: ThinkingLevel;
21
21
  isBackground?: boolean;
22
+ /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
23
+ isolation?: IsolationMode;
22
24
  /** Called on tool start/end with activity info (for streaming progress to UI). */
23
25
  onToolActivity?: (activity: ToolActivity) => void;
24
26
  /** Called on streaming text deltas from the assistant response. */
25
27
  onTextDelta?: (delta: string, fullText: string) => void;
26
28
  /** Called when the agent session is created (for accessing session stats). */
27
29
  onSessionCreated?: (session: AgentSession) => void;
30
+ /** Called at the end of each agentic turn with the cumulative count. */
31
+ onTurnEnd?: (turnCount: number) => void;
28
32
  }
29
33
  export declare class AgentManager {
30
34
  private agents;
31
35
  private cleanupInterval;
32
36
  private onComplete?;
37
+ private onStart?;
33
38
  private maxConcurrent;
34
39
  /** Queue of background agents waiting to start. */
35
40
  private queue;
36
41
  /** Number of currently running background agents. */
37
42
  private runningBackground;
38
- constructor(onComplete?: OnAgentComplete, maxConcurrent?: number);
43
+ constructor(onComplete?: OnAgentComplete, maxConcurrent?: number, onStart?: OnAgentStart);
39
44
  /** Update the max concurrent background agents limit. */
40
45
  setMaxConcurrent(n: number): void;
41
46
  getMaxConcurrent(): number;
@@ -60,9 +65,18 @@ export declare class AgentManager {
60
65
  getRecord(id: string): AgentRecord | undefined;
61
66
  listAgents(): AgentRecord[];
62
67
  abort(id: string): boolean;
68
+ /** Dispose a record's session and remove it from the map. */
69
+ private removeRecord;
63
70
  private cleanup;
71
+ /**
72
+ * Remove all completed/stopped/errored records immediately.
73
+ * Called on session start/switch so tasks from a prior session don't persist.
74
+ */
75
+ clearCompleted(): void;
64
76
  /** Whether any agents are still running or queued. */
65
77
  hasRunning(): boolean;
78
+ /** Abort all running and queued agents immediately. */
79
+ abortAll(): number;
66
80
  /** Wait for all running and queued agents to complete (including queued ones). */
67
81
  waitForAll(): Promise<void>;
68
82
  dispose(): void;
@@ -6,20 +6,23 @@
6
6
  * Foreground agents bypass the queue (they block the parent anyway).
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
- import { runAgent, resumeAgent } from "./agent-runner.js";
9
+ import { resumeAgent, runAgent } from "./agent-runner.js";
10
+ import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
10
11
  /** Default max concurrent background agents. */
11
12
  const DEFAULT_MAX_CONCURRENT = 4;
12
13
  export class AgentManager {
13
14
  agents = new Map();
14
15
  cleanupInterval;
15
16
  onComplete;
17
+ onStart;
16
18
  maxConcurrent;
17
19
  /** Queue of background agents waiting to start. */
18
20
  queue = [];
19
21
  /** Number of currently running background agents. */
20
22
  runningBackground = 0;
21
- constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
23
+ constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart) {
22
24
  this.onComplete = onComplete;
25
+ this.onStart = onStart;
23
26
  this.maxConcurrent = maxConcurrent;
24
27
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
25
28
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
@@ -65,22 +68,47 @@ export class AgentManager {
65
68
  record.startedAt = Date.now();
66
69
  if (options.isBackground)
67
70
  this.runningBackground++;
68
- const promise = runAgent(ctx, type, prompt, {
71
+ this.onStart?.(record);
72
+ // Worktree isolation: create a temporary git worktree if requested
73
+ let worktreeCwd;
74
+ let worktreeWarning = "";
75
+ if (options.isolation === "worktree") {
76
+ const wt = createWorktree(ctx.cwd, id);
77
+ if (wt) {
78
+ record.worktree = wt;
79
+ worktreeCwd = wt.path;
80
+ }
81
+ else {
82
+ 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.]";
83
+ }
84
+ }
85
+ // Prepend worktree warning to prompt if isolation failed
86
+ const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
87
+ const promise = runAgent(ctx, type, effectivePrompt, {
69
88
  pi,
70
89
  model: options.model,
71
90
  maxTurns: options.maxTurns,
72
91
  isolated: options.isolated,
73
92
  inheritContext: options.inheritContext,
74
93
  thinkingLevel: options.thinkingLevel,
94
+ cwd: worktreeCwd,
75
95
  signal: record.abortController.signal,
76
96
  onToolActivity: (activity) => {
77
97
  if (activity.type === "end")
78
98
  record.toolUses++;
79
99
  options.onToolActivity?.(activity);
80
100
  },
101
+ onTurnEnd: options.onTurnEnd,
81
102
  onTextDelta: options.onTextDelta,
82
103
  onSessionCreated: (session) => {
83
104
  record.session = session;
105
+ // Flush any steers that arrived before the session was ready
106
+ if (record.pendingSteers?.length) {
107
+ for (const msg of record.pendingSteers) {
108
+ session.steer(msg).catch(() => { });
109
+ }
110
+ record.pendingSteers = undefined;
111
+ }
84
112
  options.onSessionCreated?.(session);
85
113
  },
86
114
  })
@@ -92,6 +120,23 @@ export class AgentManager {
92
120
  record.result = responseText;
93
121
  record.session = session;
94
122
  record.completedAt ??= Date.now();
123
+ // Final flush of streaming output file
124
+ if (record.outputCleanup) {
125
+ try {
126
+ record.outputCleanup();
127
+ }
128
+ catch { /* ignore */ }
129
+ record.outputCleanup = undefined;
130
+ }
131
+ // Clean up worktree if used
132
+ if (record.worktree) {
133
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
134
+ record.worktreeResult = wtResult;
135
+ if (wtResult.hasChanges && wtResult.branch) {
136
+ record.result = (record.result ?? "") +
137
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
138
+ }
139
+ }
95
140
  if (options.isBackground) {
96
141
  this.runningBackground--;
97
142
  this.onComplete?.(record);
@@ -106,6 +151,22 @@ export class AgentManager {
106
151
  }
107
152
  record.error = err instanceof Error ? err.message : String(err);
108
153
  record.completedAt ??= Date.now();
154
+ // Final flush of streaming output file on error
155
+ if (record.outputCleanup) {
156
+ try {
157
+ record.outputCleanup();
158
+ }
159
+ catch { /* ignore */ }
160
+ record.outputCleanup = undefined;
161
+ }
162
+ // Best-effort worktree cleanup on error
163
+ if (record.worktree) {
164
+ try {
165
+ const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
166
+ record.worktreeResult = wtResult;
167
+ }
168
+ catch { /* ignore cleanup errors */ }
169
+ }
109
170
  if (options.isBackground) {
110
171
  this.runningBackground--;
111
172
  this.onComplete?.(record);
@@ -190,6 +251,12 @@ export class AgentManager {
190
251
  record.completedAt = Date.now();
191
252
  return true;
192
253
  }
254
+ /** Dispose a record's session and remove it from the map. */
255
+ removeRecord(id, record) {
256
+ record.session?.dispose?.();
257
+ record.session = undefined;
258
+ this.agents.delete(id);
259
+ }
193
260
  cleanup() {
194
261
  const cutoff = Date.now() - 10 * 60_000;
195
262
  for (const [id, record] of this.agents) {
@@ -197,18 +264,48 @@ export class AgentManager {
197
264
  continue;
198
265
  if ((record.completedAt ?? 0) >= cutoff)
199
266
  continue;
200
- // Dispose and clear session so memory can be reclaimed
201
- if (record.session) {
202
- record.session.dispose();
203
- record.session = undefined;
204
- }
205
- this.agents.delete(id);
267
+ this.removeRecord(id, record);
268
+ }
269
+ }
270
+ /**
271
+ * Remove all completed/stopped/errored records immediately.
272
+ * Called on session start/switch so tasks from a prior session don't persist.
273
+ */
274
+ clearCompleted() {
275
+ for (const [id, record] of this.agents) {
276
+ if (record.status === "running" || record.status === "queued")
277
+ continue;
278
+ this.removeRecord(id, record);
206
279
  }
207
280
  }
208
281
  /** Whether any agents are still running or queued. */
209
282
  hasRunning() {
210
283
  return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
211
284
  }
285
+ /** Abort all running and queued agents immediately. */
286
+ abortAll() {
287
+ let count = 0;
288
+ // Clear queued agents first
289
+ for (const queued of this.queue) {
290
+ const record = this.agents.get(queued.id);
291
+ if (record) {
292
+ record.status = "stopped";
293
+ record.completedAt = Date.now();
294
+ count++;
295
+ }
296
+ }
297
+ this.queue = [];
298
+ // Abort running agents
299
+ for (const record of this.agents.values()) {
300
+ if (record.status === "running") {
301
+ record.abortController?.abort();
302
+ record.status = "stopped";
303
+ record.completedAt = Date.now();
304
+ count++;
305
+ }
306
+ }
307
+ return count;
308
+ }
212
309
  /** Wait for all running and queued agents to complete (including queued ones). */
213
310
  async waitForAll() {
214
311
  // Loop because drainQueue respects the concurrency limit — as running
@@ -232,5 +329,10 @@ export class AgentManager {
232
329
  record.session?.dispose();
233
330
  }
234
331
  this.agents.clear();
332
+ // Prune any orphaned git worktrees (crash recovery)
333
+ try {
334
+ pruneWorktrees(process.cwd());
335
+ }
336
+ catch { /* ignore */ }
235
337
  }
236
338
  }
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
- import { type AgentSession, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
4
  import type { Model } from "@mariozechner/pi-ai";
5
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
6
+ import { type AgentSession, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
7
  import type { SubagentType, ThinkingLevel } from "./types.js";
8
- /** Get the default max turns value. */
9
- export declare function getDefaultMaxTurns(): number;
10
- /** Set the default max turns value (minimum 1). */
11
- export declare function setDefaultMaxTurns(n: number): void;
8
+ /** Get the default max turns value. undefined = unlimited. */
9
+ export declare function getDefaultMaxTurns(): number | undefined;
10
+ /** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
11
+ export declare function setDefaultMaxTurns(n: number | undefined): void;
12
12
  /** Get the grace turns value. */
13
13
  export declare function getGraceTurns(): number;
14
14
  /** Set the grace turns value (minimum 1). */
@@ -27,11 +27,15 @@ export interface RunOptions {
27
27
  isolated?: boolean;
28
28
  inheritContext?: boolean;
29
29
  thinkingLevel?: ThinkingLevel;
30
+ /** Override working directory (e.g. for worktree isolation). */
31
+ cwd?: string;
30
32
  /** Called on tool start/end with activity info. */
31
33
  onToolActivity?: (activity: ToolActivity) => void;
32
34
  /** Called on streaming text deltas from the assistant response. */
33
35
  onTextDelta?: (delta: string, fullText: string) => void;
34
36
  onSessionCreated?: (session: AgentSession) => void;
37
+ /** Called at the end of each agentic turn with the cumulative count. */
38
+ onTurnEnd?: (turnCount: number) => void;
35
39
  }
36
40
  export interface RunResult {
37
41
  responseText: string;
@@ -2,18 +2,20 @@
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
4
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
5
- import { getToolsForType, getConfig, getAgentConfig } from "./agent-types.js";
6
- import { buildAgentPrompt } from "./prompts.js";
5
+ import { getAgentConfig, getConfig, getMemoryTools, getReadOnlyMemoryTools, getToolsForType } from "./agent-types.js";
7
6
  import { buildParentContext, extractText } from "./context.js";
8
7
  import { detectEnv } from "./env.js";
8
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
9
+ import { buildAgentPrompt } from "./prompts.js";
10
+ import { preloadSkills } from "./skill-loader.js";
9
11
  /** Names of tools registered by this extension that subagents must NOT inherit. */
10
12
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
11
- /** Default max turns to prevent subagents from looping indefinitely. */
12
- let defaultMaxTurns = 50;
13
- /** Get the default max turns value. */
13
+ /** Default max turns. undefined = unlimited (no turn limit). */
14
+ let defaultMaxTurns;
15
+ /** Get the default max turns value. undefined = unlimited. */
14
16
  export function getDefaultMaxTurns() { return defaultMaxTurns; }
15
- /** Set the default max turns value (minimum 1). */
16
- export function setDefaultMaxTurns(n) { defaultMaxTurns = Math.max(1, n); }
17
+ /** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
18
+ export function setDefaultMaxTurns(n) { defaultMaxTurns = n != null ? Math.max(1, n) : undefined; }
17
19
  /** Additional turns allowed after the soft limit steer message. */
18
20
  let graceTurns = 5;
19
21
  /** Get the grace turns value. */
@@ -73,13 +75,52 @@ function forwardAbortSignal(session, signal) {
73
75
  export async function runAgent(ctx, type, prompt, options) {
74
76
  const config = getConfig(type);
75
77
  const agentConfig = getAgentConfig(type);
76
- const env = await detectEnv(options.pi, ctx.cwd);
78
+ // Resolve working directory: worktree override > parent cwd
79
+ const effectiveCwd = options.cwd ?? ctx.cwd;
80
+ const env = await detectEnv(options.pi, effectiveCwd);
77
81
  // Get parent system prompt for append-mode agents
78
82
  const parentSystemPrompt = ctx.getSystemPrompt();
83
+ // Build prompt extras (memory, skill preloading)
84
+ const extras = {};
85
+ // Resolve extensions/skills: isolated overrides to false
86
+ const extensions = options.isolated ? false : config.extensions;
87
+ const skills = options.isolated ? false : config.skills;
88
+ // Skill preloading: when skills is string[], preload their content into prompt
89
+ if (Array.isArray(skills)) {
90
+ const loaded = preloadSkills(skills, effectiveCwd);
91
+ if (loaded.length > 0) {
92
+ extras.skillBlocks = loaded;
93
+ }
94
+ }
95
+ let tools = getToolsForType(type, effectiveCwd);
96
+ // Persistent memory: detect write capability and branch accordingly.
97
+ // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
98
+ if (agentConfig?.memory) {
99
+ const existingNames = new Set(tools.map(t => t.name));
100
+ const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
101
+ const effectivelyHas = (name) => existingNames.has(name) && !denied?.has(name);
102
+ const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
103
+ if (hasWriteTools) {
104
+ // Read-write memory: add any missing memory tools (read/write/edit)
105
+ const memTools = getMemoryTools(effectiveCwd, existingNames);
106
+ if (memTools.length > 0)
107
+ tools = [...tools, ...memTools];
108
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
109
+ }
110
+ else {
111
+ // Read-only memory: only add read tool, use read-only prompt
112
+ if (!existingNames.has("read")) {
113
+ const readTools = getReadOnlyMemoryTools(effectiveCwd, existingNames);
114
+ if (readTools.length > 0)
115
+ tools = [...tools, ...readTools];
116
+ }
117
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
118
+ }
119
+ }
79
120
  // Build system prompt from agent config
80
121
  let systemPrompt;
81
122
  if (agentConfig) {
82
- systemPrompt = buildAgentPrompt(agentConfig, ctx.cwd, env, parentSystemPrompt);
123
+ systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
83
124
  }
84
125
  else {
85
126
  // Unknown type fallback: general-purpose (defensive — unreachable in practice
@@ -94,17 +135,16 @@ export async function runAgent(ctx, type, prompt, options) {
94
135
  inheritContext: false,
95
136
  runInBackground: false,
96
137
  isolated: false,
97
- }, ctx.cwd, env, parentSystemPrompt);
138
+ }, effectiveCwd, env, parentSystemPrompt, extras);
98
139
  }
99
- const tools = getToolsForType(type, ctx.cwd);
100
- // Resolve extensions/skills: isolated overrides to false
101
- const extensions = options.isolated ? false : config.extensions;
102
- const skills = options.isolated ? false : config.skills;
140
+ // When skills is string[], we've already preloaded them into the prompt.
141
+ // Still pass noSkills: true since we don't need the skill loader to load them again.
142
+ const noSkills = skills === false || Array.isArray(skills);
103
143
  // Load extensions/skills: true or string[] → load; false → don't
104
144
  const loader = new DefaultResourceLoader({
105
- cwd: ctx.cwd,
145
+ cwd: effectiveCwd,
106
146
  noExtensions: extensions === false,
107
- noSkills: skills === false,
147
+ noSkills,
108
148
  noPromptTemplates: true,
109
149
  noThemes: true,
110
150
  systemPromptOverride: () => systemPrompt,
@@ -115,8 +155,8 @@ export async function runAgent(ctx, type, prompt, options) {
115
155
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
116
156
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
117
157
  const sessionOpts = {
118
- cwd: ctx.cwd,
119
- sessionManager: SessionManager.inMemory(ctx.cwd),
158
+ cwd: effectiveCwd,
159
+ sessionManager: SessionManager.inMemory(effectiveCwd),
120
160
  settingsManager: SettingsManager.create(),
121
161
  modelRegistry: ctx.modelRegistry,
122
162
  model,
@@ -128,13 +168,19 @@ export async function runAgent(ctx, type, prompt, options) {
128
168
  }
129
169
  // createAgentSession's type signature may not include thinkingLevel yet
130
170
  const { session } = await createAgentSession(sessionOpts);
171
+ // Build disallowed tools set from agent config
172
+ const disallowedSet = agentConfig?.disallowedTools
173
+ ? new Set(agentConfig.disallowedTools)
174
+ : undefined;
131
175
  // Filter active tools: remove our own tools to prevent nesting,
132
- // and apply extension allowlist if specified
176
+ // apply extension allowlist if specified, and apply disallowedTools denylist
133
177
  if (extensions !== false) {
134
178
  const builtinToolNames = new Set(tools.map(t => t.name));
135
179
  const activeTools = session.getActiveToolNames().filter((t) => {
136
180
  if (EXCLUDED_TOOL_NAMES.includes(t))
137
181
  return false;
182
+ if (disallowedSet?.has(t))
183
+ return false;
138
184
  if (builtinToolNames.has(t))
139
185
  return true;
140
186
  if (Array.isArray(extensions)) {
@@ -144,6 +190,11 @@ export async function runAgent(ctx, type, prompt, options) {
144
190
  });
145
191
  session.setActiveToolsByName(activeTools);
146
192
  }
193
+ else if (disallowedSet) {
194
+ // Even with extensions disabled, apply denylist to built-in tools
195
+ const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
196
+ session.setActiveToolsByName(activeTools);
197
+ }
147
198
  options.onSessionCreated?.(session);
148
199
  // Track turns for graceful max_turns enforcement
149
200
  let turnCount = 0;
@@ -154,13 +205,16 @@ export async function runAgent(ctx, type, prompt, options) {
154
205
  const unsubTurns = session.subscribe((event) => {
155
206
  if (event.type === "turn_end") {
156
207
  turnCount++;
157
- if (!softLimitReached && turnCount >= maxTurns) {
158
- softLimitReached = true;
159
- session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
160
- }
161
- else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
162
- aborted = true;
163
- session.abort();
208
+ options.onTurnEnd?.(turnCount);
209
+ if (maxTurns != null) {
210
+ if (!softLimitReached && turnCount >= maxTurns) {
211
+ softLimitReached = true;
212
+ session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
213
+ }
214
+ else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
215
+ aborted = true;
216
+ session.abort();
217
+ }
164
218
  }
165
219
  }
166
220
  if (event.type === "message_start") {
@@ -28,6 +28,16 @@ export declare function getDefaultAgentNames(): string[];
28
28
  export declare function getUserAgentNames(): string[];
29
29
  /** Check if a type is valid and enabled (case-insensitive). */
30
30
  export declare function isValidType(type: string): boolean;
31
+ /**
32
+ * Get the tools needed for memory management (read, write, edit).
33
+ * Only returns tools that are NOT already in the provided set.
34
+ */
35
+ export declare function getMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[];
36
+ /**
37
+ * Get only the read tool for read-only memory access.
38
+ * Only returns tools that are NOT already in the provided set.
39
+ */
40
+ export declare function getReadOnlyMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[];
31
41
  /** Get built-in tools for a type (case-insensitive). */
32
42
  export declare function getToolsForType(type: string, cwd: string): AgentTool<any>[];
33
43
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
@@ -4,7 +4,7 @@
4
4
  * Merges embedded default agents with user-defined agents from .pi/agents/*.md.
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
- import { createReadTool, createBashTool, createEditTool, createWriteTool, createGrepTool, createFindTool, createLsTool, } from "@mariozechner/pi-coding-agent";
7
+ import { createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, } from "@mariozechner/pi-coding-agent";
8
8
  import { DEFAULT_AGENTS } from "./default-agents.js";
9
9
  const TOOL_FACTORIES = {
10
10
  read: (cwd) => createReadTool(cwd),
@@ -84,6 +84,28 @@ export function isValidType(type) {
84
84
  return false;
85
85
  return agents.get(key)?.enabled !== false;
86
86
  }
87
+ /** Tool names required for memory management. */
88
+ const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
89
+ /**
90
+ * Get the tools needed for memory management (read, write, edit).
91
+ * Only returns tools that are NOT already in the provided set.
92
+ */
93
+ export function getMemoryTools(cwd, existingToolNames) {
94
+ return MEMORY_TOOL_NAMES
95
+ .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
96
+ .map(n => TOOL_FACTORIES[n](cwd));
97
+ }
98
+ /** Tool names needed for read-only memory access. */
99
+ const READONLY_MEMORY_TOOL_NAMES = ["read"];
100
+ /**
101
+ * Get only the read tool for read-only memory access.
102
+ * Only returns tools that are NOT already in the provided set.
103
+ */
104
+ export function getReadOnlyMemoryTools(cwd, existingToolNames) {
105
+ return READONLY_MEMORY_TOOL_NAMES
106
+ .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
107
+ .map(n => TOOL_FACTORIES[n](cwd));
108
+ }
87
109
  /** Get built-in tools for a type (case-insensitive). */
88
110
  export function getToolsForType(type, cwd) {
89
111
  const key = resolveKey(type);