@tintinweb/pi-subagents 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
3
+ *
4
+ * Displays a scrollable, live-updating view of an agent's conversation.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ */
7
+ import { type Component, type TUI } from "@mariozechner/pi-tui";
8
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
9
+ import type { Theme } from "./agent-widget.js";
10
+ import { type AgentActivity } from "./agent-widget.js";
11
+ import type { AgentRecord } from "../types.js";
12
+ export declare class ConversationViewer implements Component {
13
+ private tui;
14
+ private session;
15
+ private record;
16
+ private activity;
17
+ private theme;
18
+ private done;
19
+ private scrollOffset;
20
+ private autoScroll;
21
+ private unsubscribe;
22
+ private lastInnerW;
23
+ private closed;
24
+ constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void);
25
+ handleInput(data: string): void;
26
+ render(width: number): string[];
27
+ invalidate(): void;
28
+ dispose(): void;
29
+ private viewportHeight;
30
+ private buildContentLines;
31
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
3
+ *
4
+ * Displays a scrollable, live-updating view of an agent's conversation.
5
+ * Subscribes to session events for real-time streaming updates.
6
+ */
7
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
+ import { formatTokens, formatDuration, getDisplayName, getPromptModeLabel, describeActivity } from "./agent-widget.js";
9
+ import { extractText } from "../context.js";
10
+ /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
11
+ const CHROME_LINES = 6;
12
+ const MIN_VIEWPORT = 3;
13
+ export class ConversationViewer {
14
+ tui;
15
+ session;
16
+ record;
17
+ activity;
18
+ theme;
19
+ done;
20
+ scrollOffset = 0;
21
+ autoScroll = true;
22
+ unsubscribe;
23
+ lastInnerW = 0;
24
+ closed = false;
25
+ constructor(tui, session, record, activity, theme, done) {
26
+ this.tui = tui;
27
+ this.session = session;
28
+ this.record = record;
29
+ this.activity = activity;
30
+ this.theme = theme;
31
+ this.done = done;
32
+ this.unsubscribe = session.subscribe(() => {
33
+ if (this.closed)
34
+ return;
35
+ this.tui.requestRender();
36
+ });
37
+ }
38
+ handleInput(data) {
39
+ if (matchesKey(data, "escape") || matchesKey(data, "q")) {
40
+ this.closed = true;
41
+ this.done(undefined);
42
+ return;
43
+ }
44
+ const totalLines = this.buildContentLines(this.lastInnerW).length;
45
+ const viewportHeight = this.viewportHeight();
46
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
47
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
48
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
49
+ this.autoScroll = this.scrollOffset >= maxScroll;
50
+ }
51
+ else if (matchesKey(data, "down") || matchesKey(data, "j")) {
52
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
53
+ this.autoScroll = this.scrollOffset >= maxScroll;
54
+ }
55
+ else if (matchesKey(data, "pageUp")) {
56
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
57
+ this.autoScroll = false;
58
+ }
59
+ else if (matchesKey(data, "pageDown")) {
60
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
61
+ this.autoScroll = this.scrollOffset >= maxScroll;
62
+ }
63
+ else if (matchesKey(data, "home")) {
64
+ this.scrollOffset = 0;
65
+ this.autoScroll = false;
66
+ }
67
+ else if (matchesKey(data, "end")) {
68
+ this.scrollOffset = maxScroll;
69
+ this.autoScroll = true;
70
+ }
71
+ }
72
+ render(width) {
73
+ if (width < 6)
74
+ return []; // too narrow for any meaningful rendering
75
+ const th = this.theme;
76
+ const innerW = width - 4; // border + padding
77
+ this.lastInnerW = innerW;
78
+ const lines = [];
79
+ const pad = (s, len) => {
80
+ const vis = visibleWidth(s);
81
+ return s + " ".repeat(Math.max(0, len - vis));
82
+ };
83
+ const row = (content) => th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
84
+ const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
85
+ const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
86
+ const hrMid = row(th.fg("dim", "─".repeat(innerW)));
87
+ // Header
88
+ lines.push(hrTop);
89
+ const name = getDisplayName(this.record.type);
90
+ const modeLabel = getPromptModeLabel(this.record.type);
91
+ const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
92
+ const statusIcon = this.record.status === "running"
93
+ ? th.fg("accent", "●")
94
+ : this.record.status === "completed"
95
+ ? th.fg("success", "✓")
96
+ : this.record.status === "error"
97
+ ? th.fg("error", "✗")
98
+ : th.fg("dim", "○");
99
+ const duration = formatDuration(this.record.startedAt, this.record.completedAt);
100
+ const headerParts = [duration];
101
+ const toolUses = this.activity?.toolUses ?? this.record.toolUses;
102
+ if (toolUses > 0)
103
+ headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
104
+ if (this.activity?.session) {
105
+ try {
106
+ const tokens = this.activity.session.getSessionStats().tokens.total;
107
+ if (tokens > 0)
108
+ headerParts.push(formatTokens(tokens));
109
+ }
110
+ catch { /* */ }
111
+ }
112
+ lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
113
+ lines.push(hrMid);
114
+ // Content area — rebuild every render (live data, no cache needed)
115
+ const contentLines = this.buildContentLines(innerW);
116
+ const viewportHeight = this.viewportHeight();
117
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
118
+ if (this.autoScroll) {
119
+ this.scrollOffset = maxScroll;
120
+ }
121
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
122
+ const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
123
+ for (let i = 0; i < viewportHeight; i++) {
124
+ lines.push(row(visible[i] ?? ""));
125
+ }
126
+ // Footer
127
+ lines.push(hrMid);
128
+ const scrollPct = contentLines.length <= viewportHeight
129
+ ? "100%"
130
+ : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
131
+ const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
132
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
133
+ const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
134
+ lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
135
+ lines.push(hrBot);
136
+ return lines;
137
+ }
138
+ invalidate() { }
139
+ dispose() {
140
+ this.closed = true;
141
+ if (this.unsubscribe) {
142
+ this.unsubscribe();
143
+ this.unsubscribe = undefined;
144
+ }
145
+ }
146
+ // ---- Private ----
147
+ viewportHeight() {
148
+ return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
149
+ }
150
+ buildContentLines(width) {
151
+ if (width <= 0)
152
+ return [];
153
+ const th = this.theme;
154
+ const messages = this.session.messages;
155
+ const lines = [];
156
+ if (messages.length === 0) {
157
+ lines.push(th.fg("dim", "(waiting for first message...)"));
158
+ return lines;
159
+ }
160
+ let needsSeparator = false;
161
+ for (const msg of messages) {
162
+ if (msg.role === "user") {
163
+ const text = typeof msg.content === "string"
164
+ ? msg.content
165
+ : extractText(msg.content);
166
+ if (!text.trim())
167
+ continue;
168
+ if (needsSeparator)
169
+ lines.push(th.fg("dim", "───"));
170
+ lines.push(th.fg("accent", "[User]"));
171
+ for (const line of wrapTextWithAnsi(text.trim(), width)) {
172
+ lines.push(line);
173
+ }
174
+ }
175
+ else if (msg.role === "assistant") {
176
+ const textParts = [];
177
+ const toolCalls = [];
178
+ for (const c of msg.content) {
179
+ if (c.type === "text" && c.text)
180
+ textParts.push(c.text);
181
+ else if (c.type === "toolCall") {
182
+ toolCalls.push(c.toolName ?? "unknown");
183
+ }
184
+ }
185
+ if (needsSeparator)
186
+ lines.push(th.fg("dim", "───"));
187
+ lines.push(th.bold("[Assistant]"));
188
+ if (textParts.length > 0) {
189
+ for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
190
+ lines.push(line);
191
+ }
192
+ }
193
+ for (const name of toolCalls) {
194
+ lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
195
+ }
196
+ }
197
+ else if (msg.role === "toolResult") {
198
+ const text = extractText(msg.content);
199
+ const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
200
+ if (!truncated.trim())
201
+ continue;
202
+ if (needsSeparator)
203
+ lines.push(th.fg("dim", "───"));
204
+ lines.push(th.fg("dim", "[Result]"));
205
+ for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
206
+ lines.push(th.fg("dim", line));
207
+ }
208
+ }
209
+ else if (msg.role === "bashExecution") {
210
+ const bash = msg;
211
+ if (needsSeparator)
212
+ lines.push(th.fg("dim", "───"));
213
+ lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
214
+ if (bash.output?.trim()) {
215
+ const out = bash.output.length > 500
216
+ ? bash.output.slice(0, 500) + "... (truncated)"
217
+ : bash.output;
218
+ for (const line of wrapTextWithAnsi(out.trim(), width)) {
219
+ lines.push(th.fg("dim", line));
220
+ }
221
+ }
222
+ }
223
+ else {
224
+ continue;
225
+ }
226
+ needsSeparator = true;
227
+ }
228
+ // Streaming indicator for running agents
229
+ if (this.record.status === "running" && this.activity) {
230
+ const act = describeActivity(this.activity.activeTools, this.activity.responseText);
231
+ lines.push("");
232
+ lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
233
+ }
234
+ return lines;
235
+ }
236
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -11,9 +11,11 @@ import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-age
11
11
  import type { Model } from "@mariozechner/pi-ai";
12
12
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
13
13
  import { runAgent, resumeAgent, type ToolActivity } from "./agent-runner.js";
14
- import type { SubagentType, AgentRecord, ThinkingLevel } from "./types.js";
14
+ import type { SubagentType, AgentRecord, ThinkingLevel, IsolationMode } from "./types.js";
15
+ import { createWorktree, cleanupWorktree, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
15
16
 
16
17
  export type OnAgentComplete = (record: AgentRecord) => void;
18
+ export type OnAgentStart = (record: AgentRecord) => void;
17
19
 
18
20
  /** Default max concurrent background agents. */
19
21
  const DEFAULT_MAX_CONCURRENT = 4;
@@ -34,6 +36,8 @@ interface SpawnOptions {
34
36
  inheritContext?: boolean;
35
37
  thinkingLevel?: ThinkingLevel;
36
38
  isBackground?: boolean;
39
+ /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
40
+ isolation?: IsolationMode;
37
41
  /** Called on tool start/end with activity info (for streaming progress to UI). */
38
42
  onToolActivity?: (activity: ToolActivity) => void;
39
43
  /** Called on streaming text deltas from the assistant response. */
@@ -46,6 +50,7 @@ export class AgentManager {
46
50
  private agents = new Map<string, AgentRecord>();
47
51
  private cleanupInterval: ReturnType<typeof setInterval>;
48
52
  private onComplete?: OnAgentComplete;
53
+ private onStart?: OnAgentStart;
49
54
  private maxConcurrent: number;
50
55
 
51
56
  /** Queue of background agents waiting to start. */
@@ -53,8 +58,9 @@ export class AgentManager {
53
58
  /** Number of currently running background agents. */
54
59
  private runningBackground = 0;
55
60
 
56
- constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
61
+ constructor(onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart) {
57
62
  this.onComplete = onComplete;
63
+ this.onStart = onStart;
58
64
  this.maxConcurrent = maxConcurrent;
59
65
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
60
66
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
@@ -112,14 +118,32 @@ export class AgentManager {
112
118
  record.status = "running";
113
119
  record.startedAt = Date.now();
114
120
  if (options.isBackground) this.runningBackground++;
121
+ this.onStart?.(record);
115
122
 
116
- const promise = runAgent(ctx, type, prompt, {
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;
138
+
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);
@@ -271,6 +315,28 @@ export class AgentManager {
271
315
  }
272
316
  }
273
317
 
318
+ /** Whether any agents are still running or queued. */
319
+ hasRunning(): boolean {
320
+ return [...this.agents.values()].some(
321
+ r => r.status === "running" || r.status === "queued",
322
+ );
323
+ }
324
+
325
+ /** Wait for all running and queued agents to complete (including queued ones). */
326
+ async waitForAll(): Promise<void> {
327
+ // Loop because drainQueue respects the concurrency limit — as running
328
+ // agents finish they start queued ones, which need awaiting too.
329
+ while (true) {
330
+ this.drainQueue();
331
+ const pending = [...this.agents.values()]
332
+ .filter(r => r.status === "running" || r.status === "queued")
333
+ .map(r => r.promise)
334
+ .filter(Boolean);
335
+ if (pending.length === 0) break;
336
+ await Promise.allSettled(pending);
337
+ }
338
+ }
339
+
274
340
  dispose() {
275
341
  clearInterval(this.cleanupInterval);
276
342
  // Clear queue
@@ -279,5 +345,7 @@ export class AgentManager {
279
345
  record.session?.dispose();
280
346
  }
281
347
  this.agents.clear();
348
+ // Prune any orphaned git worktrees (crash recovery)
349
+ try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
282
350
  }
283
351
  }
@@ -13,10 +13,12 @@ import {
13
13
  } from "@mariozechner/pi-coding-agent";
14
14
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
15
15
  import type { Model } from "@mariozechner/pi-ai";
16
- import { getToolsForType, getConfig, getAgentConfig } from "./agent-types.js";
17
- import { buildAgentPrompt } from "./prompts.js";
16
+ import { getToolsForType, getConfig, getAgentConfig, getMemoryTools, getReadOnlyMemoryTools } from "./agent-types.js";
17
+ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
18
18
  import { buildParentContext, extractText } from "./context.js";
19
19
  import { detectEnv } from "./env.js";
20
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
21
+ import { preloadSkills } from "./skill-loader.js";
20
22
  import type { SubagentType, ThinkingLevel } from "./types.js";
21
23
 
22
24
  /** Names of tools registered by this extension that subagents must NOT inherit. */
@@ -84,6 +86,8 @@ export interface RunOptions {
84
86
  isolated?: boolean;
85
87
  inheritContext?: boolean;
86
88
  thinkingLevel?: ThinkingLevel;
89
+ /** Override working directory (e.g. for worktree isolation). */
90
+ cwd?: string;
87
91
  /** Called on tool start/end with activity info. */
88
92
  onToolActivity?: (activity: ToolActivity) => void;
89
93
  /** Called on streaming text deltas from the assistant response. */
@@ -136,15 +140,59 @@ export async function runAgent(
136
140
  ): Promise<RunResult> {
137
141
  const config = getConfig(type);
138
142
  const agentConfig = getAgentConfig(type);
139
- const env = await detectEnv(options.pi, ctx.cwd);
143
+
144
+ // Resolve working directory: worktree override > parent cwd
145
+ const effectiveCwd = options.cwd ?? ctx.cwd;
146
+
147
+ const env = await detectEnv(options.pi, effectiveCwd);
140
148
 
141
149
  // Get parent system prompt for append-mode agents
142
150
  const parentSystemPrompt = ctx.getSystemPrompt();
143
151
 
152
+ // Build prompt extras (memory, skill preloading)
153
+ const extras: PromptExtras = {};
154
+
155
+ // Resolve extensions/skills: isolated overrides to false
156
+ const extensions = options.isolated ? false : config.extensions;
157
+ const skills = options.isolated ? false : config.skills;
158
+
159
+ // Skill preloading: when skills is string[], preload their content into prompt
160
+ if (Array.isArray(skills)) {
161
+ const loaded = preloadSkills(skills, effectiveCwd);
162
+ if (loaded.length > 0) {
163
+ extras.skillBlocks = loaded;
164
+ }
165
+ }
166
+
167
+ let tools = getToolsForType(type, effectiveCwd);
168
+
169
+ // Persistent memory: detect write capability and branch accordingly.
170
+ // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
171
+ if (agentConfig?.memory) {
172
+ const existingNames = new Set(tools.map(t => t.name));
173
+ const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
174
+ const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
175
+ const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
176
+
177
+ if (hasWriteTools) {
178
+ // Read-write memory: add any missing memory tools (read/write/edit)
179
+ const memTools = getMemoryTools(effectiveCwd, existingNames);
180
+ if (memTools.length > 0) tools = [...tools, ...memTools];
181
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
182
+ } else {
183
+ // Read-only memory: only add read tool, use read-only prompt
184
+ if (!existingNames.has("read")) {
185
+ const readTools = getReadOnlyMemoryTools(effectiveCwd, existingNames);
186
+ if (readTools.length > 0) tools = [...tools, ...readTools];
187
+ }
188
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
189
+ }
190
+ }
191
+
144
192
  // Build system prompt from agent config
145
193
  let systemPrompt: string;
146
194
  if (agentConfig) {
147
- systemPrompt = buildAgentPrompt(agentConfig, ctx.cwd, env, parentSystemPrompt);
195
+ systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
148
196
  } else {
149
197
  // Unknown type fallback: general-purpose (defensive — unreachable in practice
150
198
  // since index.ts resolves unknown types to "general-purpose" before calling runAgent)
@@ -158,20 +206,18 @@ export async function runAgent(
158
206
  inheritContext: false,
159
207
  runInBackground: false,
160
208
  isolated: false,
161
- }, ctx.cwd, env, parentSystemPrompt);
209
+ }, effectiveCwd, env, parentSystemPrompt, extras);
162
210
  }
163
211
 
164
- const tools = getToolsForType(type, ctx.cwd);
165
-
166
- // Resolve extensions/skills: isolated overrides to false
167
- const extensions = options.isolated ? false : config.extensions;
168
- const skills = options.isolated ? false : config.skills;
212
+ // When skills is string[], we've already preloaded them into the prompt.
213
+ // Still pass noSkills: true since we don't need the skill loader to load them again.
214
+ const noSkills = skills === false || Array.isArray(skills);
169
215
 
170
216
  // Load extensions/skills: true or string[] → load; false → don't
171
217
  const loader = new DefaultResourceLoader({
172
- cwd: ctx.cwd,
218
+ cwd: effectiveCwd,
173
219
  noExtensions: extensions === false,
174
- noSkills: skills === false,
220
+ noSkills,
175
221
  noPromptTemplates: true,
176
222
  noThemes: true,
177
223
  systemPromptOverride: () => systemPrompt,
@@ -187,8 +233,8 @@ export async function runAgent(
187
233
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
188
234
 
189
235
  const sessionOpts: Record<string, unknown> = {
190
- cwd: ctx.cwd,
191
- sessionManager: SessionManager.inMemory(ctx.cwd),
236
+ cwd: effectiveCwd,
237
+ sessionManager: SessionManager.inMemory(effectiveCwd),
192
238
  settingsManager: SettingsManager.create(),
193
239
  modelRegistry: ctx.modelRegistry,
194
240
  model,
@@ -202,12 +248,18 @@ export async function runAgent(
202
248
  // createAgentSession's type signature may not include thinkingLevel yet
203
249
  const { session } = await createAgentSession(sessionOpts as Parameters<typeof createAgentSession>[0]);
204
250
 
251
+ // Build disallowed tools set from agent config
252
+ const disallowedSet = agentConfig?.disallowedTools
253
+ ? new Set(agentConfig.disallowedTools)
254
+ : undefined;
255
+
205
256
  // Filter active tools: remove our own tools to prevent nesting,
206
- // and apply extension allowlist if specified
257
+ // apply extension allowlist if specified, and apply disallowedTools denylist
207
258
  if (extensions !== false) {
208
259
  const builtinToolNames = new Set(tools.map(t => t.name));
209
260
  const activeTools = session.getActiveToolNames().filter((t) => {
210
261
  if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
262
+ if (disallowedSet?.has(t)) return false;
211
263
  if (builtinToolNames.has(t)) return true;
212
264
  if (Array.isArray(extensions)) {
213
265
  return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
@@ -215,6 +267,10 @@ export async function runAgent(
215
267
  return true;
216
268
  });
217
269
  session.setActiveToolsByName(activeTools);
270
+ } else if (disallowedSet) {
271
+ // Even with extensions disabled, apply denylist to built-in tools
272
+ const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
273
+ session.setActiveToolsByName(activeTools);
218
274
  }
219
275
 
220
276
  options.onSessionCreated?.(session);
@@ -109,6 +109,32 @@ export function isValidType(type: string): boolean {
109
109
  return agents.get(key)?.enabled !== false;
110
110
  }
111
111
 
112
+ /** Tool names required for memory management. */
113
+ const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
114
+
115
+ /**
116
+ * Get the tools needed for memory management (read, write, edit).
117
+ * Only returns tools that are NOT already in the provided set.
118
+ */
119
+ export function getMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[] {
120
+ return MEMORY_TOOL_NAMES
121
+ .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
122
+ .map(n => TOOL_FACTORIES[n](cwd));
123
+ }
124
+
125
+ /** Tool names needed for read-only memory access. */
126
+ const READONLY_MEMORY_TOOL_NAMES = ["read"];
127
+
128
+ /**
129
+ * Get only the read tool for read-only memory access.
130
+ * Only returns tools that are NOT already in the provided set.
131
+ */
132
+ export function getReadOnlyMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[] {
133
+ return READONLY_MEMORY_TOOL_NAMES
134
+ .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
135
+ .map(n => TOOL_FACTORIES[n](cwd));
136
+ }
137
+
112
138
  /** Get built-in tools for a type (case-insensitive). */
113
139
  export function getToolsForType(type: string, cwd: string): AgentTool<any>[] {
114
140
  const key = resolveKey(type);
@@ -6,7 +6,7 @@ import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
6
  import { readFileSync, readdirSync, existsSync } from "node:fs";
7
7
  import { join, basename } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import type { AgentConfig, ThinkingLevel } from "./types.js";
9
+ import type { AgentConfig, ThinkingLevel, MemoryScope, IsolationMode } from "./types.js";
10
10
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
11
11
 
12
12
  /**
@@ -56,6 +56,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
56
56
  displayName: str(fm.display_name),
57
57
  description: str(fm.description) ?? name,
58
58
  builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
59
+ disallowedTools: csvListOptional(fm.disallowed_tools),
59
60
  extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
60
61
  skills: inheritField(fm.skills ?? fm.inherit_skills),
61
62
  model: str(fm.model),
@@ -66,6 +67,8 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
66
67
  inheritContext: fm.inherit_context === true,
67
68
  runInBackground: fm.run_in_background === true,
68
69
  isolated: fm.isolated === true,
70
+ memory: parseMemory(fm.memory),
71
+ isolation: fm.isolation === "worktree" ? "worktree" : undefined,
69
72
  enabled: fm.enabled !== false, // default true; explicitly false disables
70
73
  source,
71
74
  });
@@ -86,14 +89,40 @@ function positiveInt(val: unknown): number | undefined {
86
89
  }
87
90
 
88
91
  /**
89
- * Parse a comma-separated list field.
92
+ * Parse a raw CSV field value into items, or undefined if absent/empty/"none".
93
+ */
94
+ function parseCsvField(val: unknown): string[] | undefined {
95
+ if (val === undefined || val === null) return undefined;
96
+ const s = String(val).trim();
97
+ if (!s || s === "none") return undefined;
98
+ const items = s.split(",").map(t => t.trim()).filter(Boolean);
99
+ return items.length > 0 ? items : undefined;
100
+ }
101
+
102
+ /**
103
+ * Parse a comma-separated list field with defaults.
90
104
  * omitted → defaults; "none"/empty → []; csv → listed items.
91
105
  */
92
106
  function csvList(val: unknown, defaults: string[]): string[] {
93
107
  if (val === undefined || val === null) return defaults;
94
- const s = String(val).trim();
95
- if (!s || s === "none") return [];
96
- return s.split(",").map(t => t.trim()).filter(Boolean);
108
+ return parseCsvField(val) ?? [];
109
+ }
110
+
111
+ /**
112
+ * Parse an optional comma-separated list field.
113
+ * omitted → undefined; "none"/empty → undefined; csv → listed items.
114
+ */
115
+ function csvListOptional(val: unknown): string[] | undefined {
116
+ return parseCsvField(val);
117
+ }
118
+
119
+ /**
120
+ * Parse a memory scope field.
121
+ * omitted → undefined; "user"/"project"/"local" → MemoryScope.
122
+ */
123
+ function parseMemory(val: unknown): MemoryScope | undefined {
124
+ if (val === "user" || val === "project" || val === "local") return val;
125
+ return undefined;
97
126
  }
98
127
 
99
128
  /**