@tintinweb/pi-subagents 0.4.1 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +85 -1
- package/package.json +1 -1
- package/src/agent-manager.ts +74 -9
- 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 +86 -3
- package/src/memory.ts +165 -0
- package/src/prompts.ts +24 -2
- package/src/skill-loader.ts +79 -0
- package/src/types.ts +18 -0
- package/src/worktree.ts +162 -0
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,8 +279,15 @@ 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
|
|
|
287
|
+
// Clear completed tasks when a new session starts (e.g. /new) so stale records don't persist
|
|
288
|
+
pi.on("session_start", () => { manager.clearCompleted(); });
|
|
289
|
+
pi.on("session_switch", () => { manager.clearCompleted(); });
|
|
290
|
+
|
|
241
291
|
// Wait for all subagents on shutdown, then dispose the manager
|
|
242
292
|
pi.on("session_shutdown", async () => {
|
|
243
293
|
delete (globalThis as any)[MANAGER_KEY];
|
|
@@ -362,6 +412,7 @@ Guidelines:
|
|
|
362
412
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
363
413
|
- Use thinking to control extended thinking level.
|
|
364
414
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
415
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
|
|
365
416
|
- 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
417
|
parameters: Type.Object({
|
|
367
418
|
prompt: Type.String({
|
|
@@ -410,6 +461,11 @@ Guidelines:
|
|
|
410
461
|
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
411
462
|
}),
|
|
412
463
|
),
|
|
464
|
+
isolation: Type.Optional(
|
|
465
|
+
Type.Literal("worktree", {
|
|
466
|
+
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.',
|
|
467
|
+
}),
|
|
468
|
+
),
|
|
413
469
|
join_mode: Type.Optional(
|
|
414
470
|
Type.Union([
|
|
415
471
|
Type.Literal("async"),
|
|
@@ -544,6 +600,7 @@ Guidelines:
|
|
|
544
600
|
const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
|
|
545
601
|
const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
|
|
546
602
|
const isolated = params.isolated ?? customConfig?.isolated ?? false;
|
|
603
|
+
const isolation = params.isolation ?? customConfig?.isolation;
|
|
547
604
|
|
|
548
605
|
// Build display tags for non-default config
|
|
549
606
|
const parentModelId = ctx.model?.id;
|
|
@@ -556,6 +613,7 @@ Guidelines:
|
|
|
556
613
|
if (modeLabel) agentTags.push(modeLabel);
|
|
557
614
|
if (thinking) agentTags.push(`thinking: ${thinking}`);
|
|
558
615
|
if (isolated) agentTags.push("isolated");
|
|
616
|
+
if (isolation === "worktree") agentTags.push("worktree");
|
|
559
617
|
// Shared base fields for all AgentDetails in this call
|
|
560
618
|
const detailBase = {
|
|
561
619
|
displayName,
|
|
@@ -596,6 +654,7 @@ Guidelines:
|
|
|
596
654
|
inheritContext,
|
|
597
655
|
thinkingLevel: thinking,
|
|
598
656
|
isBackground: true,
|
|
657
|
+
isolation,
|
|
599
658
|
...bgCallbacks,
|
|
600
659
|
});
|
|
601
660
|
|
|
@@ -618,6 +677,15 @@ Guidelines:
|
|
|
618
677
|
agentActivity.set(id, bgState);
|
|
619
678
|
widget.ensureTimer();
|
|
620
679
|
widget.update();
|
|
680
|
+
|
|
681
|
+
// Emit created event
|
|
682
|
+
pi.events.emit("subagents:created", {
|
|
683
|
+
id,
|
|
684
|
+
type: subagentType,
|
|
685
|
+
description: params.description,
|
|
686
|
+
isBackground: true,
|
|
687
|
+
});
|
|
688
|
+
|
|
621
689
|
const isQueued = record?.status === "queued";
|
|
622
690
|
return textResult(
|
|
623
691
|
`Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
@@ -684,6 +752,7 @@ Guidelines:
|
|
|
684
752
|
isolated,
|
|
685
753
|
inheritContext,
|
|
686
754
|
thinkingLevel: thinking,
|
|
755
|
+
isolation,
|
|
687
756
|
...fgCallbacks,
|
|
688
757
|
});
|
|
689
758
|
|
|
@@ -747,8 +816,12 @@ Guidelines:
|
|
|
747
816
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
748
817
|
}
|
|
749
818
|
|
|
750
|
-
// Wait for completion if requested
|
|
819
|
+
// Wait for completion if requested.
|
|
820
|
+
// Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
|
|
821
|
+
// (attached earlier at spawn time) and always runs before this await resumes.
|
|
822
|
+
// Setting the flag here prevents a redundant follow-up notification.
|
|
751
823
|
if (params.wait && record.status === "running" && record.promise) {
|
|
824
|
+
record.resultConsumed = true;
|
|
752
825
|
await record.promise;
|
|
753
826
|
}
|
|
754
827
|
|
|
@@ -812,11 +885,15 @@ Guidelines:
|
|
|
812
885
|
return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
|
|
813
886
|
}
|
|
814
887
|
if (!record.session) {
|
|
815
|
-
|
|
888
|
+
// Session not ready yet — queue the steer for delivery once initialized
|
|
889
|
+
(record.pendingSteers ??= []).push(params.message);
|
|
890
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
891
|
+
return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
|
|
816
892
|
}
|
|
817
893
|
|
|
818
894
|
try {
|
|
819
895
|
await steerAgent(record.session, params.message);
|
|
896
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
820
897
|
return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
|
|
821
898
|
} catch (err) {
|
|
822
899
|
return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1092,9 +1169,12 @@ Guidelines:
|
|
|
1092
1169
|
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1093
1170
|
if (cfg.skills === false) fmFields.push("skills: false");
|
|
1094
1171
|
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1172
|
+
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
1095
1173
|
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
1096
1174
|
if (cfg.runInBackground) fmFields.push("run_in_background: true");
|
|
1097
1175
|
if (cfg.isolated) fmFields.push("isolated: true");
|
|
1176
|
+
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
1177
|
+
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
1098
1178
|
|
|
1099
1179
|
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
1100
1180
|
|
|
@@ -1214,10 +1294,13 @@ thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit
|
|
|
1214
1294
|
max_turns: <optional max agentic turns, default 50. Omit for default>
|
|
1215
1295
|
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
1216
1296
|
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
1217
|
-
skills: <true (inherit all), false (none). Default: true>
|
|
1297
|
+
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
1298
|
+
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
1218
1299
|
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
1219
1300
|
run_in_background: <true to run in background by default. Default: false>
|
|
1220
1301
|
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
1302
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
1303
|
+
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
1221
1304
|
---
|
|
1222
1305
|
|
|
1223
1306
|
<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,12 @@ 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
|
+
/** Steering messages queued before the session was ready. */
|
|
77
|
+
pendingSteers?: string[];
|
|
78
|
+
/** Worktree info if the agent is running in an isolated worktree. */
|
|
79
|
+
worktree?: { path: string; branch: string };
|
|
80
|
+
/** Worktree cleanup result after agent completion. */
|
|
81
|
+
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
export interface EnvInfo {
|