@gotgenes/pi-subagents 1.0.0
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/.markdownlint-cli2.yaml +19 -0
- package/.prettierignore +5 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +85 -0
- package/CHANGELOG.md +495 -0
- package/LICENSE +21 -0
- package/README.md +528 -0
- package/dist/agent-manager.d.ts +108 -0
- package/dist/agent-manager.js +390 -0
- package/dist/agent-runner.d.ts +93 -0
- package/dist/agent-runner.js +428 -0
- package/dist/agent-types.d.ts +48 -0
- package/dist/agent-types.js +136 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +54 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.js +127 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +119 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1731 -0
- package/dist/invocation-config.d.ts +22 -0
- package/dist/invocation-config.js +15 -0
- package/dist/memory.d.ts +49 -0
- package/dist/memory.js +151 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +62 -0
- package/dist/output-file.d.ts +24 -0
- package/dist/output-file.js +86 -0
- package/dist/prompts.d.ts +29 -0
- package/dist/prompts.js +72 -0
- package/dist/schedule-store.d.ts +36 -0
- package/dist/schedule-store.js +144 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +338 -0
- package/dist/settings.d.ts +66 -0
- package/dist/settings.js +130 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +5 -0
- package/dist/ui/agent-widget.d.ts +134 -0
- package/dist/ui/agent-widget.js +451 -0
- package/dist/ui/conversation-viewer.d.ts +35 -0
- package/dist/ui/conversation-viewer.js +252 -0
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +95 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +49 -0
- package/dist/worktree.d.ts +36 -0
- package/dist/worktree.js +139 -0
- package/docs/decisions/0001-deferred-patches.md +75 -0
- package/package.json +68 -0
- package/prek.toml +24 -0
- package/release-please-config.json +22 -0
- package/src/agent-manager.ts +482 -0
- package/src/agent-runner.ts +625 -0
- package/src/agent-types.ts +164 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +95 -0
- package/src/custom-agents.ts +136 -0
- package/src/default-agents.ts +123 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +1894 -0
- package/src/invocation-config.ts +40 -0
- package/src/memory.ts +165 -0
- package/src/model-resolver.ts +81 -0
- package/src/output-file.ts +96 -0
- package/src/prompts.ts +105 -0
- package/src/schedule-store.ts +143 -0
- package/src/schedule.ts +365 -0
- package/src/settings.ts +186 -0
- package/src/skill-loader.ts +102 -0
- package/src/types.ts +176 -0
- package/src/ui/agent-widget.ts +533 -0
- package/src/ui/conversation-viewer.ts +261 -0
- package/src/ui/schedule-menu.ts +104 -0
- package/src/usage.ts +60 -0
- package/src/worktree.ts +162 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
|
|
2
|
+
|
|
3
|
+
interface AgentInvocationParams {
|
|
4
|
+
model?: string;
|
|
5
|
+
thinking?: string;
|
|
6
|
+
max_turns?: number;
|
|
7
|
+
run_in_background?: boolean;
|
|
8
|
+
inherit_context?: boolean;
|
|
9
|
+
isolated?: boolean;
|
|
10
|
+
isolation?: IsolationMode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveAgentInvocationConfig(
|
|
14
|
+
agentConfig: AgentConfig | undefined,
|
|
15
|
+
params: AgentInvocationParams,
|
|
16
|
+
): {
|
|
17
|
+
modelInput?: string;
|
|
18
|
+
modelFromParams: boolean;
|
|
19
|
+
thinking?: ThinkingLevel;
|
|
20
|
+
maxTurns?: number;
|
|
21
|
+
inheritContext: boolean;
|
|
22
|
+
runInBackground: boolean;
|
|
23
|
+
isolated: boolean;
|
|
24
|
+
isolation?: IsolationMode;
|
|
25
|
+
} {
|
|
26
|
+
return {
|
|
27
|
+
modelInput: agentConfig?.model ?? params.model,
|
|
28
|
+
modelFromParams: agentConfig?.model == null && params.model != null,
|
|
29
|
+
thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
|
|
30
|
+
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
|
|
31
|
+
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
|
32
|
+
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
|
|
33
|
+
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
|
34
|
+
isolation: agentConfig?.isolation ?? params.isolation,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
|
|
39
|
+
return runInBackground ? defaultJoinMode : undefined;
|
|
40
|
+
}
|
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, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, } from "node:path";
|
|
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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ModelEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ModelRegistry {
|
|
12
|
+
find(provider: string, modelId: string): any;
|
|
13
|
+
getAll(): any[];
|
|
14
|
+
getAvailable?(): any[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a model string to a Model instance.
|
|
19
|
+
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
|
20
|
+
* Returns the Model on success, or an error message string on failure.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveModel(
|
|
23
|
+
input: string,
|
|
24
|
+
registry: ModelRegistry,
|
|
25
|
+
): any | string {
|
|
26
|
+
// Available models (those with auth configured)
|
|
27
|
+
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
|
|
28
|
+
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
|
|
29
|
+
|
|
30
|
+
// 1. Exact match: "provider/modelId" — only if available (has auth)
|
|
31
|
+
const slashIdx = input.indexOf("/");
|
|
32
|
+
if (slashIdx !== -1) {
|
|
33
|
+
const provider = input.slice(0, slashIdx);
|
|
34
|
+
const modelId = input.slice(slashIdx + 1);
|
|
35
|
+
if (availableSet.has(input.toLowerCase())) {
|
|
36
|
+
const found = registry.find(provider, modelId);
|
|
37
|
+
if (found) return found;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Fuzzy match against available models
|
|
42
|
+
const query = input.toLowerCase();
|
|
43
|
+
|
|
44
|
+
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
|
|
45
|
+
let bestMatch: ModelEntry | undefined;
|
|
46
|
+
let bestScore = 0;
|
|
47
|
+
|
|
48
|
+
for (const m of all) {
|
|
49
|
+
const id = m.id.toLowerCase();
|
|
50
|
+
const name = m.name.toLowerCase();
|
|
51
|
+
const full = `${m.provider}/${m.id}`.toLowerCase();
|
|
52
|
+
|
|
53
|
+
let score = 0;
|
|
54
|
+
if (id === query || full === query) {
|
|
55
|
+
score = 100; // exact
|
|
56
|
+
} else if (id.includes(query) || full.includes(query)) {
|
|
57
|
+
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
|
|
58
|
+
} else if (name.includes(query)) {
|
|
59
|
+
score = 40 + (query.length / name.length) * 20;
|
|
60
|
+
} else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
|
|
61
|
+
score = 20; // all parts present somewhere
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (score > bestScore) {
|
|
65
|
+
bestScore = score;
|
|
66
|
+
bestMatch = m;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (bestMatch && bestScore >= 20) {
|
|
71
|
+
const found = registry.find(bestMatch.provider, bestMatch.id);
|
|
72
|
+
if (found) return found;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. No match — list available models
|
|
76
|
+
const modelList = all
|
|
77
|
+
.map(m => ` ${m.provider}/${m.id}`)
|
|
78
|
+
.sort()
|
|
79
|
+
.join("\n");
|
|
80
|
+
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* output-file.ts — Streaming JSONL output file for agent transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Creates a per-agent output file that streams conversation turns as JSONL,
|
|
5
|
+
* matching Claude Code's task output file format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Encode a cwd path as a filesystem-safe directory name. Handles:
|
|
15
|
+
* - POSIX: "/home/user/project" → "home-user-project"
|
|
16
|
+
* - Windows: "C:\Users\foo\project" → "Users-foo-project"
|
|
17
|
+
* - UNC: "\\\\server\\share\\project" → "server-share-project"
|
|
18
|
+
*/
|
|
19
|
+
export function encodeCwd(cwd: string): string {
|
|
20
|
+
return cwd
|
|
21
|
+
.replace(/[/\\]/g, "-") // both separators → dash
|
|
22
|
+
.replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-")
|
|
23
|
+
.replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Create the output file path, ensuring the directory exists.
|
|
27
|
+
* Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
|
|
28
|
+
export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string {
|
|
29
|
+
const encoded = encodeCwd(cwd);
|
|
30
|
+
const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
|
|
31
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
32
|
+
// chmod is a no-op on Windows and throws on some Windows filesystems.
|
|
33
|
+
// On Unix we still want to enforce 0o700 past umask, so only swallow on Windows.
|
|
34
|
+
try {
|
|
35
|
+
chmodSync(root, 0o700);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (process.platform !== "win32") throw err;
|
|
38
|
+
}
|
|
39
|
+
const dir = join(root, encoded, sessionId, "tasks");
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
return join(dir, `${agentId}.output`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write the initial user prompt entry. */
|
|
45
|
+
export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
|
|
46
|
+
const entry = {
|
|
47
|
+
isSidechain: true,
|
|
48
|
+
agentId,
|
|
49
|
+
type: "user",
|
|
50
|
+
message: { role: "user", content: prompt },
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
cwd,
|
|
53
|
+
};
|
|
54
|
+
writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Subscribe to session events and flush new messages to the output file on each turn_end.
|
|
59
|
+
* Returns a cleanup function that does a final flush and unsubscribes.
|
|
60
|
+
*/
|
|
61
|
+
export function streamToOutputFile(
|
|
62
|
+
session: AgentSession,
|
|
63
|
+
path: string,
|
|
64
|
+
agentId: string,
|
|
65
|
+
cwd: string,
|
|
66
|
+
): () => void {
|
|
67
|
+
let writtenCount = 1; // initial user prompt already written
|
|
68
|
+
|
|
69
|
+
const flush = () => {
|
|
70
|
+
const messages = session.messages;
|
|
71
|
+
while (writtenCount < messages.length) {
|
|
72
|
+
const msg = messages[writtenCount];
|
|
73
|
+
const entry = {
|
|
74
|
+
isSidechain: true,
|
|
75
|
+
agentId,
|
|
76
|
+
type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult",
|
|
77
|
+
message: msg,
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
cwd,
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
|
|
83
|
+
} catch { /* ignore write errors */ }
|
|
84
|
+
writtenCount++;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
89
|
+
if (event.type === "turn_end") flush();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
flush();
|
|
94
|
+
unsubscribe();
|
|
95
|
+
};
|
|
96
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompts.ts — System prompt builder for agents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AgentConfig, EnvInfo } from "./types.js";
|
|
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
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the system prompt for an agent from its config.
|
|
17
|
+
*
|
|
18
|
+
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
|
|
19
|
+
* - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
|
|
20
|
+
* - "append" with empty systemPrompt: pure parent clone
|
|
21
|
+
*
|
|
22
|
+
* Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
|
|
23
|
+
* extensions (e.g. `@gotgenes/pi-permission-system`) can resolve per-agent policy
|
|
24
|
+
* inside the child session by parsing the system prompt.
|
|
25
|
+
*
|
|
26
|
+
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
27
|
+
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
|
28
|
+
*/
|
|
29
|
+
export function buildAgentPrompt(
|
|
30
|
+
config: AgentConfig,
|
|
31
|
+
cwd: string,
|
|
32
|
+
env: EnvInfo,
|
|
33
|
+
parentSystemPrompt?: string,
|
|
34
|
+
extras?: PromptExtras,
|
|
35
|
+
): string {
|
|
36
|
+
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
|
|
37
|
+
|
|
38
|
+
const envBlock = `# Environment
|
|
39
|
+
Working directory: ${cwd}
|
|
40
|
+
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
|
|
41
|
+
Platform: ${env.platform}`;
|
|
42
|
+
|
|
43
|
+
// Build optional extras suffix
|
|
44
|
+
const extraSections: string[] = [];
|
|
45
|
+
if (extras?.memoryBlock) {
|
|
46
|
+
extraSections.push(extras.memoryBlock);
|
|
47
|
+
}
|
|
48
|
+
if (extras?.skillBlocks?.length) {
|
|
49
|
+
for (const skill of extras.skillBlocks) {
|
|
50
|
+
extraSections.push(
|
|
51
|
+
`\n# Preloaded Skill: ${skill.name}\n${skill.content}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const extrasSuffix =
|
|
56
|
+
extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
|
|
57
|
+
|
|
58
|
+
if (config.promptMode === "append") {
|
|
59
|
+
const identity = parentSystemPrompt || genericBase;
|
|
60
|
+
|
|
61
|
+
const bridge = `<sub_agent_context>
|
|
62
|
+
You are operating as a sub-agent invoked to handle a specific task.
|
|
63
|
+
- Use the read tool instead of cat/head/tail
|
|
64
|
+
- Use the edit tool instead of sed/awk
|
|
65
|
+
- Use the write tool instead of echo/heredoc
|
|
66
|
+
- Use the find tool instead of bash find/ls for file search
|
|
67
|
+
- Use the grep tool instead of bash grep/rg for content search
|
|
68
|
+
- Make independent tool calls in parallel
|
|
69
|
+
- Use absolute file paths
|
|
70
|
+
- Do not use emojis
|
|
71
|
+
- Be concise but complete
|
|
72
|
+
</sub_agent_context>`;
|
|
73
|
+
|
|
74
|
+
const customSection = config.systemPrompt?.trim()
|
|
75
|
+
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
76
|
+
: "";
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
activeAgentTag +
|
|
80
|
+
envBlock +
|
|
81
|
+
"\n\n<inherited_system_prompt>\n" +
|
|
82
|
+
identity +
|
|
83
|
+
"\n</inherited_system_prompt>\n\n" +
|
|
84
|
+
bridge +
|
|
85
|
+
customSection +
|
|
86
|
+
extrasSuffix
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// "replace" mode — env header + the config's full system prompt
|
|
91
|
+
const replaceHeader = `You are a pi coding agent sub-agent.
|
|
92
|
+
You have been invoked to handle a specific task autonomously.
|
|
93
|
+
|
|
94
|
+
${envBlock}`;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
|
|
102
|
+
const genericBase = `# Role
|
|
103
|
+
You are a general-purpose coding agent for complex, multi-step tasks.
|
|
104
|
+
You have full access to read, write, edit files, and execute commands.
|
|
105
|
+
Do what has been asked; nothing more, nothing less.`;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schedule-store.ts — File-backed store for scheduled subagents.
|
|
3
|
+
*
|
|
4
|
+
* Session-scoped: each pi session owns its own schedules at
|
|
5
|
+
* `<cwd>/.pi/subagent-schedules/<sessionId>.json`. `/new` starts a fresh
|
|
6
|
+
* empty store; `/resume` reloads.
|
|
7
|
+
*
|
|
8
|
+
* Concurrency model lifted from pi-chonky-tasks/src/task-store.ts: every
|
|
9
|
+
* mutation acquires a PID-based exclusion lock, re-reads the latest state
|
|
10
|
+
* from disk, applies the change, atomic-writes via temp+rename, releases.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import type { ScheduledSubagent, ScheduleStoreData } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const LOCK_RETRY_MS = 50;
|
|
18
|
+
const LOCK_MAX_RETRIES = 100;
|
|
19
|
+
|
|
20
|
+
function isProcessRunning(pid: number): boolean {
|
|
21
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function acquireLock(lockPath: string): void {
|
|
25
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
|
|
28
|
+
return;
|
|
29
|
+
} catch (e: any) {
|
|
30
|
+
if (e.code === "EEXIST") {
|
|
31
|
+
try {
|
|
32
|
+
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
33
|
+
if (pid && !isProcessRunning(pid)) {
|
|
34
|
+
unlinkSync(lockPath);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
} catch { /* ignore — try again */ }
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Failed to acquire schedule lock: ${lockPath}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function releaseLock(lockPath: string): void {
|
|
49
|
+
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve the storage path for a session-scoped store. */
|
|
53
|
+
export function resolveStorePath(cwd: string, sessionId: string): string {
|
|
54
|
+
return join(cwd, ".pi", "subagent-schedules", `${sessionId}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ScheduleStore {
|
|
58
|
+
private filePath: string;
|
|
59
|
+
private lockPath: string;
|
|
60
|
+
private jobs = new Map<string, ScheduledSubagent>();
|
|
61
|
+
|
|
62
|
+
constructor(filePath: string) {
|
|
63
|
+
this.filePath = filePath;
|
|
64
|
+
this.lockPath = filePath + ".lock";
|
|
65
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
66
|
+
this.load();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Load from disk into the in-memory cache. Silent on parse errors. */
|
|
70
|
+
private load(): void {
|
|
71
|
+
if (!existsSync(this.filePath)) return;
|
|
72
|
+
try {
|
|
73
|
+
const data: ScheduleStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
74
|
+
this.jobs.clear();
|
|
75
|
+
for (const j of data.jobs ?? []) this.jobs.set(j.id, j);
|
|
76
|
+
} catch { /* corrupt — start fresh, next save rewrites */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Atomic write via temp file + rename (POSIX-atomic). */
|
|
80
|
+
private save(): void {
|
|
81
|
+
const data: ScheduleStoreData = { version: 1, jobs: [...this.jobs.values()] };
|
|
82
|
+
const tmp = this.filePath + ".tmp";
|
|
83
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
84
|
+
renameSync(tmp, this.filePath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Acquire lock → reload → mutate → save → release. */
|
|
88
|
+
private withLock<T>(fn: () => T): T {
|
|
89
|
+
acquireLock(this.lockPath);
|
|
90
|
+
try {
|
|
91
|
+
this.load();
|
|
92
|
+
const result = fn();
|
|
93
|
+
this.save();
|
|
94
|
+
return result;
|
|
95
|
+
} finally {
|
|
96
|
+
releaseLock(this.lockPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read-only — returns a snapshot of the in-memory cache. */
|
|
101
|
+
list(): ScheduledSubagent[] {
|
|
102
|
+
return [...this.jobs.values()];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read-only check — uses the cache. */
|
|
106
|
+
hasName(name: string, exceptId?: string): boolean {
|
|
107
|
+
for (const j of this.jobs.values()) {
|
|
108
|
+
if (j.id !== exceptId && j.name === name) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get(id: string): ScheduledSubagent | undefined {
|
|
114
|
+
return this.jobs.get(id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
add(job: ScheduledSubagent): void {
|
|
118
|
+
this.withLock(() => {
|
|
119
|
+
this.jobs.set(job.id, job);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
update(id: string, patch: Partial<ScheduledSubagent>): ScheduledSubagent | undefined {
|
|
124
|
+
return this.withLock(() => {
|
|
125
|
+
const existing = this.jobs.get(id);
|
|
126
|
+
if (!existing) return undefined;
|
|
127
|
+
const updated = { ...existing, ...patch };
|
|
128
|
+
this.jobs.set(id, updated);
|
|
129
|
+
return updated;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
remove(id: string): boolean {
|
|
134
|
+
return this.withLock(() => this.jobs.delete(id));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Delete the backing file (used when no jobs remain, optional cleanup). */
|
|
138
|
+
deleteFileIfEmpty(): void {
|
|
139
|
+
if (this.jobs.size === 0 && existsSync(this.filePath)) {
|
|
140
|
+
try { unlinkSync(this.filePath); } catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|