@pi-unipi/subagents 0.1.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/dist/agent-manager.d.ts +69 -0
- package/dist/agent-manager.d.ts.map +1 -0
- package/dist/agent-manager.js +240 -0
- package/dist/agent-manager.js.map +1 -0
- package/dist/agent-runner.d.ts +50 -0
- package/dist/agent-runner.d.ts.map +1 -0
- package/dist/agent-runner.js +238 -0
- package/dist/agent-runner.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +115 -0
- package/dist/config.js.map +1 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.d.ts.map +1 -0
- package/dist/custom-agents.js +94 -0
- package/dist/custom-agents.js.map +1 -0
- package/dist/file-lock.d.ts +42 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +91 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +270 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts.d.ts +13 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +31 -0
- package/dist/prompts.js.map +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/widget.d.ts +22 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +108 -0
- package/dist/widget.js.map +1 -0
- package/package.json +30 -0
- package/src/agent-manager.ts +302 -0
- package/src/agent-runner.ts +306 -0
- package/src/config.ts +128 -0
- package/src/custom-agents.ts +106 -0
- package/src/file-lock.ts +102 -0
- package/src/index.ts +323 -0
- package/src/prompts.ts +39 -0
- package/src/skills/explore/SKILL.md +32 -0
- package/src/skills/work/SKILL.md +40 -0
- package/src/types.ts +86 -0
- package/src/widget.ts +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Agent runner
|
|
3
|
+
*
|
|
4
|
+
* Creates sessions, runs agents, collects results.
|
|
5
|
+
* Forwards abort signals for ESC propagation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
9
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
type AgentSession,
|
|
12
|
+
type AgentSessionEvent,
|
|
13
|
+
createAgentSession,
|
|
14
|
+
DefaultResourceLoader,
|
|
15
|
+
type ExtensionAPI,
|
|
16
|
+
getAgentDir,
|
|
17
|
+
SessionManager,
|
|
18
|
+
SettingsManager,
|
|
19
|
+
} from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import type { AgentConfig, AgentType, ThinkingLevel } from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** Tools excluded from subagents to prevent nesting. */
|
|
23
|
+
const EXCLUDED_TOOL_NAMES = ["Agent", "get_result"];
|
|
24
|
+
|
|
25
|
+
/** All known built-in tool names. */
|
|
26
|
+
const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
27
|
+
|
|
28
|
+
/** Default max turns. undefined = unlimited. */
|
|
29
|
+
let defaultMaxTurns: number | undefined;
|
|
30
|
+
|
|
31
|
+
export function getDefaultMaxTurns(): number | undefined {
|
|
32
|
+
return defaultMaxTurns;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setDefaultMaxTurns(n: number | undefined): void {
|
|
36
|
+
defaultMaxTurns = n == null || n === 0 ? undefined : Math.max(1, n);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Grace turns after soft limit. */
|
|
40
|
+
let graceTurns = 5;
|
|
41
|
+
|
|
42
|
+
export function getGraceTurns(): number {
|
|
43
|
+
return graceTurns;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function setGraceTurns(n: number): void {
|
|
47
|
+
graceTurns = Math.max(1, n);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tool activity info. */
|
|
51
|
+
export interface ToolActivity {
|
|
52
|
+
type: "start" | "end";
|
|
53
|
+
toolName: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for running an agent. */
|
|
57
|
+
export interface RunOptions {
|
|
58
|
+
pi: ExtensionAPI;
|
|
59
|
+
model?: Model<any>;
|
|
60
|
+
maxTurns?: number;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
isolated?: boolean;
|
|
63
|
+
inheritContext?: boolean;
|
|
64
|
+
thinkingLevel?: ThinkingLevel;
|
|
65
|
+
cwd?: string;
|
|
66
|
+
onToolActivity?: (activity: ToolActivity) => void;
|
|
67
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
68
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
69
|
+
onTurnEnd?: (turnCount: number) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Result from running an agent. */
|
|
73
|
+
export interface RunResult {
|
|
74
|
+
responseText: string;
|
|
75
|
+
session: AgentSession;
|
|
76
|
+
aborted: boolean;
|
|
77
|
+
steered: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Collect last assistant message text. */
|
|
81
|
+
function collectResponseText(session: AgentSession) {
|
|
82
|
+
let text = "";
|
|
83
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
84
|
+
if (event.type === "message_start") {
|
|
85
|
+
text = "";
|
|
86
|
+
}
|
|
87
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
88
|
+
text += event.assistantMessageEvent.delta;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return { getText: () => text, unsubscribe };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Get last assistant text from session history. */
|
|
95
|
+
function getLastAssistantText(session: AgentSession): string {
|
|
96
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
97
|
+
const msg = session.messages[i];
|
|
98
|
+
if (msg.role !== "assistant") continue;
|
|
99
|
+
const text = msg.content
|
|
100
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
101
|
+
.map((c) => c.text)
|
|
102
|
+
.join("")
|
|
103
|
+
.trim();
|
|
104
|
+
if (text) return text;
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Wire abort signal to session. */
|
|
110
|
+
function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
|
|
111
|
+
if (!signal) return () => {};
|
|
112
|
+
const onAbort = () => session.abort();
|
|
113
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
114
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Get tool names for agent type. */
|
|
118
|
+
function getToolNamesForType(type: AgentType, config?: AgentConfig): string[] {
|
|
119
|
+
if (config?.builtinToolNames?.length) {
|
|
120
|
+
return [...config.builtinToolNames];
|
|
121
|
+
}
|
|
122
|
+
return [...BUILTIN_TOOL_NAMES];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolve model from config. */
|
|
126
|
+
function resolveDefaultModel(
|
|
127
|
+
parentModel: Model<any> | undefined,
|
|
128
|
+
configModel?: string,
|
|
129
|
+
): Model<any> | undefined {
|
|
130
|
+
// For now, just use parent model. Full model resolution requires registry.
|
|
131
|
+
return parentModel;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run an agent session.
|
|
136
|
+
*/
|
|
137
|
+
export async function runAgent(
|
|
138
|
+
ctx: ExtensionContext,
|
|
139
|
+
type: AgentType,
|
|
140
|
+
prompt: string,
|
|
141
|
+
options: RunOptions,
|
|
142
|
+
): Promise<RunResult> {
|
|
143
|
+
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
144
|
+
|
|
145
|
+
// Build system prompt
|
|
146
|
+
const agentConfig = options as any; // Will be properly typed later
|
|
147
|
+
const parentSystemPrompt = ctx.getSystemPrompt();
|
|
148
|
+
|
|
149
|
+
let systemPrompt: string;
|
|
150
|
+
if (options.isolated) {
|
|
151
|
+
systemPrompt = `You are a ${type} agent. Follow the task instructions precisely. Do not ask questions.`;
|
|
152
|
+
} else {
|
|
153
|
+
systemPrompt = parentSystemPrompt + `\n\nYou are a ${type} agent. Follow the task instructions precisely.`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Get tool names
|
|
157
|
+
let toolNames = getToolNamesForType(type);
|
|
158
|
+
|
|
159
|
+
// Create resource loader
|
|
160
|
+
const agentDir = getAgentDir();
|
|
161
|
+
const loader = new DefaultResourceLoader({
|
|
162
|
+
cwd: effectiveCwd,
|
|
163
|
+
agentDir,
|
|
164
|
+
noExtensions: options.isolated,
|
|
165
|
+
noSkills: options.isolated,
|
|
166
|
+
noPromptTemplates: true,
|
|
167
|
+
noThemes: true,
|
|
168
|
+
noContextFiles: true,
|
|
169
|
+
systemPromptOverride: () => systemPrompt,
|
|
170
|
+
appendSystemPromptOverride: () => [],
|
|
171
|
+
});
|
|
172
|
+
await loader.reload();
|
|
173
|
+
|
|
174
|
+
// Resolve model
|
|
175
|
+
const model = options.model ?? resolveDefaultModel(ctx.model);
|
|
176
|
+
|
|
177
|
+
// Create session
|
|
178
|
+
const sessionOpts: Parameters<typeof createAgentSession>[0] = {
|
|
179
|
+
cwd: effectiveCwd,
|
|
180
|
+
agentDir,
|
|
181
|
+
sessionManager: SessionManager.inMemory(effectiveCwd),
|
|
182
|
+
settingsManager: SettingsManager.create(effectiveCwd, agentDir),
|
|
183
|
+
modelRegistry: ctx.modelRegistry,
|
|
184
|
+
model,
|
|
185
|
+
tools: toolNames,
|
|
186
|
+
resourceLoader: loader,
|
|
187
|
+
};
|
|
188
|
+
if (options.thinkingLevel) {
|
|
189
|
+
sessionOpts.thinkingLevel = options.thinkingLevel;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { session } = await createAgentSession(sessionOpts);
|
|
193
|
+
|
|
194
|
+
// Filter out our tools to prevent nesting
|
|
195
|
+
const activeTools = session.getActiveToolNames().filter((t) => {
|
|
196
|
+
if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
|
|
197
|
+
return true;
|
|
198
|
+
});
|
|
199
|
+
session.setActiveToolsByName(activeTools);
|
|
200
|
+
|
|
201
|
+
// Bind extensions
|
|
202
|
+
await session.bindExtensions({
|
|
203
|
+
onError: (err) => {
|
|
204
|
+
options.onToolActivity?.({
|
|
205
|
+
type: "end",
|
|
206
|
+
toolName: `extension-error:${err.extensionPath}`,
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
options.onSessionCreated?.(session);
|
|
212
|
+
|
|
213
|
+
// Track turns
|
|
214
|
+
let turnCount = 0;
|
|
215
|
+
const maxTurns = options.maxTurns ?? defaultMaxTurns;
|
|
216
|
+
let softLimitReached = false;
|
|
217
|
+
let aborted = false;
|
|
218
|
+
|
|
219
|
+
let currentMessageText = "";
|
|
220
|
+
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
|
|
221
|
+
if (event.type === "turn_end") {
|
|
222
|
+
turnCount++;
|
|
223
|
+
options.onTurnEnd?.(turnCount);
|
|
224
|
+
if (maxTurns != null) {
|
|
225
|
+
if (!softLimitReached && turnCount >= maxTurns) {
|
|
226
|
+
softLimitReached = true;
|
|
227
|
+
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
228
|
+
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
229
|
+
aborted = true;
|
|
230
|
+
session.abort();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (event.type === "message_start") {
|
|
235
|
+
currentMessageText = "";
|
|
236
|
+
}
|
|
237
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
238
|
+
currentMessageText += event.assistantMessageEvent.delta;
|
|
239
|
+
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
240
|
+
}
|
|
241
|
+
if (event.type === "tool_execution_start") {
|
|
242
|
+
options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
243
|
+
}
|
|
244
|
+
if (event.type === "tool_execution_end") {
|
|
245
|
+
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const collector = collectResponseText(session);
|
|
250
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await session.prompt(prompt);
|
|
254
|
+
} finally {
|
|
255
|
+
unsubTurns();
|
|
256
|
+
collector.unsubscribe();
|
|
257
|
+
cleanupAbort();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const responseText = collector.getText().trim() || getLastAssistantText(session);
|
|
261
|
+
return { responseText, session, aborted, steered: softLimitReached };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get conversation text from a session.
|
|
266
|
+
*/
|
|
267
|
+
export function getAgentConversation(session: AgentSession): string {
|
|
268
|
+
const parts: string[] = [];
|
|
269
|
+
|
|
270
|
+
for (const msg of session.messages) {
|
|
271
|
+
if (msg.role === "user") {
|
|
272
|
+
const content = msg.content;
|
|
273
|
+
const text = typeof content === "string"
|
|
274
|
+
? content
|
|
275
|
+
: content
|
|
276
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
277
|
+
.map((c) => c.text)
|
|
278
|
+
.join("");
|
|
279
|
+
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
|
280
|
+
} else if (msg.role === "assistant") {
|
|
281
|
+
const textParts: string[] = [];
|
|
282
|
+
const toolCalls: string[] = [];
|
|
283
|
+
const content = msg.content;
|
|
284
|
+
if (typeof content !== "string") {
|
|
285
|
+
for (const c of content) {
|
|
286
|
+
if (c.type === "text" && c.text) textParts.push(c.text);
|
|
287
|
+
else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? "unknown"}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
|
291
|
+
if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
|
|
292
|
+
} else if (msg.role === "toolResult") {
|
|
293
|
+
const content = msg.content;
|
|
294
|
+
const text = typeof content === "string"
|
|
295
|
+
? content
|
|
296
|
+
: content
|
|
297
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
298
|
+
.map((c) => c.text)
|
|
299
|
+
.join("");
|
|
300
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
301
|
+
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return parts.join("\n\n");
|
|
306
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Config management
|
|
3
|
+
*
|
|
4
|
+
* Loads config from ~/.unipc/config/subagents.json (global)
|
|
5
|
+
* and <workspace>/.unipi/config/subagents.json (override).
|
|
6
|
+
* Auto-generates on first run. Repairs corrupted files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import type { SubagentsConfig } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CONFIG: SubagentsConfig = {
|
|
15
|
+
maxConcurrent: 4,
|
|
16
|
+
enabled: true,
|
|
17
|
+
types: {
|
|
18
|
+
explore: { enabled: true },
|
|
19
|
+
work: { enabled: true },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Get global config path: ~/.unipi/config/subagents.json */
|
|
24
|
+
function getGlobalConfigPath(): string {
|
|
25
|
+
return join(homedir(), ".unipi", "config", "subagents.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Get workspace config path: <cwd>/.unipi/config/subagents.json */
|
|
29
|
+
function getWorkspaceConfigPath(cwd: string): string {
|
|
30
|
+
return join(cwd, ".unipi", "config", "subagents.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Ensure directory exists. */
|
|
34
|
+
function ensureDir(filePath: string): void {
|
|
35
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Write config atomically (write then rename). */
|
|
42
|
+
function writeConfigAtomic(filePath: string, config: SubagentsConfig): void {
|
|
43
|
+
const tmpPath = filePath + ".tmp";
|
|
44
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
|
|
45
|
+
renameSync(tmpPath, filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Load and parse config from a path. Returns null on failure. */
|
|
49
|
+
function loadConfigFromPath(filePath: string): SubagentsConfig | null {
|
|
50
|
+
if (!existsSync(filePath)) return null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const content = readFileSync(filePath, "utf-8");
|
|
54
|
+
const parsed = JSON.parse(content);
|
|
55
|
+
// Basic validation
|
|
56
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
57
|
+
return parsed as SubagentsConfig;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Repair corrupted config: rename to .bak and generate fresh. */
|
|
64
|
+
function repairCorrupted(filePath: string): SubagentsConfig {
|
|
65
|
+
const backupPath = filePath + ".bak";
|
|
66
|
+
try {
|
|
67
|
+
renameSync(filePath, backupPath);
|
|
68
|
+
} catch {
|
|
69
|
+
// If rename fails, just overwrite
|
|
70
|
+
}
|
|
71
|
+
writeConfigAtomic(filePath, DEFAULT_CONFIG);
|
|
72
|
+
return DEFAULT_CONFIG;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Initialize config on extension start.
|
|
77
|
+
* - If missing: generate with defaults
|
|
78
|
+
* - If corrupted: rename to .bak, generate fresh
|
|
79
|
+
* - If valid: load
|
|
80
|
+
*/
|
|
81
|
+
export function initConfig(cwd: string): SubagentsConfig {
|
|
82
|
+
const globalPath = getGlobalConfigPath();
|
|
83
|
+
|
|
84
|
+
// Ensure global config dir exists
|
|
85
|
+
ensureDir(globalPath);
|
|
86
|
+
|
|
87
|
+
// Load or create global config
|
|
88
|
+
let globalConfig = loadConfigFromPath(globalPath);
|
|
89
|
+
if (globalConfig === null) {
|
|
90
|
+
globalConfig = repairCorrupted(globalPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Load workspace override if exists
|
|
94
|
+
const workspacePath = getWorkspaceConfigPath(cwd);
|
|
95
|
+
const workspaceConfig = loadConfigFromPath(workspacePath);
|
|
96
|
+
|
|
97
|
+
if (workspaceConfig) {
|
|
98
|
+
// Merge: workspace overrides global on any field present
|
|
99
|
+
return {
|
|
100
|
+
...globalConfig,
|
|
101
|
+
...workspaceConfig,
|
|
102
|
+
types: {
|
|
103
|
+
...globalConfig.types,
|
|
104
|
+
...workspaceConfig.types,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return globalConfig;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Save global config.
|
|
114
|
+
*/
|
|
115
|
+
export function saveGlobalConfig(config: SubagentsConfig): void {
|
|
116
|
+
const globalPath = getGlobalConfigPath();
|
|
117
|
+
ensureDir(globalPath);
|
|
118
|
+
writeConfigAtomic(globalPath, config);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Save workspace config.
|
|
123
|
+
*/
|
|
124
|
+
export function saveWorkspaceConfig(cwd: string, config: SubagentsConfig): void {
|
|
125
|
+
const workspacePath = getWorkspaceConfigPath(cwd);
|
|
126
|
+
ensureDir(workspacePath);
|
|
127
|
+
writeConfigAtomic(workspacePath, config);
|
|
128
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Custom agent loader
|
|
3
|
+
*
|
|
4
|
+
* Discovers agent types from:
|
|
5
|
+
* - <workspace>/.unipc/config/agents/*.md (project, highest priority)
|
|
6
|
+
* - ~/.unipc/config/agents/*.md (global)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import type { AgentConfig } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Get project agents directory. */
|
|
16
|
+
function getProjectAgentsDir(cwd: string): string {
|
|
17
|
+
return join(cwd, ".unipc", "config", "agents");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Get global agents directory. */
|
|
21
|
+
function getGlobalAgentsDir(): string {
|
|
22
|
+
return join(homedir(), ".unipc", "config", "agents");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** All known built-in tool names. */
|
|
26
|
+
const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load a single agent from a .md file.
|
|
30
|
+
*/
|
|
31
|
+
function loadAgentFromFile(filePath: string, source: "project" | "global"): AgentConfig | null {
|
|
32
|
+
try {
|
|
33
|
+
const content = readFileSync(filePath, "utf-8");
|
|
34
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
35
|
+
|
|
36
|
+
if (!frontmatter || typeof frontmatter !== "object") {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const name = filePath.split("/").pop()?.replace(/\.md$/, "") ?? "unknown";
|
|
41
|
+
|
|
42
|
+
// Parse tools from comma-separated string
|
|
43
|
+
const toolsStr = (frontmatter as any).tools as string | undefined;
|
|
44
|
+
const builtinToolNames = toolsStr
|
|
45
|
+
? toolsStr.split(",").map((t) => t.trim()).filter((t) => BUILTIN_TOOL_NAMES.includes(t))
|
|
46
|
+
: [...BUILTIN_TOOL_NAMES];
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
displayName: (frontmatter as any).display_name as string | undefined,
|
|
51
|
+
description: ((frontmatter as any).description as string) ?? `${name} agent`,
|
|
52
|
+
builtinToolNames,
|
|
53
|
+
disallowedTools: ((frontmatter as any).disallowed_tools as string | undefined)
|
|
54
|
+
?.split(",")
|
|
55
|
+
.map((t) => t.trim()),
|
|
56
|
+
extensions: (frontmatter as any).extensions !== false,
|
|
57
|
+
skills: (frontmatter as any).skills !== false,
|
|
58
|
+
model: (frontmatter as any).model as string | undefined,
|
|
59
|
+
thinking: (frontmatter as any).thinking as any,
|
|
60
|
+
maxTurns: (frontmatter as any).max_turns as number | undefined,
|
|
61
|
+
systemPrompt: body.trim(),
|
|
62
|
+
promptMode: ((frontmatter as any).prompt_mode as "replace" | "append") ?? "replace",
|
|
63
|
+
inheritContext: (frontmatter as any).inherit_context as boolean | undefined,
|
|
64
|
+
runInBackground: (frontmatter as any).run_in_background as boolean | undefined,
|
|
65
|
+
isolated: (frontmatter as any).isolated as boolean | undefined,
|
|
66
|
+
enabled: (frontmatter as any).enabled !== false,
|
|
67
|
+
source,
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load all custom agents from project and global directories.
|
|
76
|
+
* Project agents override global agents with the same name.
|
|
77
|
+
*/
|
|
78
|
+
export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
|
|
79
|
+
const agents = new Map<string, AgentConfig>();
|
|
80
|
+
|
|
81
|
+
// Load global agents first
|
|
82
|
+
const globalDir = getGlobalAgentsDir();
|
|
83
|
+
if (existsSync(globalDir)) {
|
|
84
|
+
const files = readdirSync(globalDir).filter((f) => f.endsWith(".md"));
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
const agent = loadAgentFromFile(join(globalDir, file), "global");
|
|
87
|
+
if (agent) {
|
|
88
|
+
agents.set(agent.name, agent);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Load project agents (overrides global)
|
|
94
|
+
const projectDir = getProjectAgentsDir(cwd);
|
|
95
|
+
if (existsSync(projectDir)) {
|
|
96
|
+
const files = readdirSync(projectDir).filter((f) => f.endsWith(".md"));
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const agent = loadAgentFromFile(join(projectDir, file), "project");
|
|
99
|
+
if (agent) {
|
|
100
|
+
agents.set(agent.name, agent);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return agents;
|
|
106
|
+
}
|
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/subagents — Per-file transparent locking
|
|
3
|
+
*
|
|
4
|
+
* Agents never see lock errors. Write tool queues internally.
|
|
5
|
+
* Per-file granularity: locking src/auth.ts doesn't block src/login.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FileLockEntry } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export class FileLock {
|
|
11
|
+
/** Active locks by file path. */
|
|
12
|
+
private locks = new Map<string, FileLockEntry>();
|
|
13
|
+
/** Queue of waiting acquires per file path. */
|
|
14
|
+
private queues = new Map<string, Array<() => void>>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Acquire a lock on a file. Blocks until available.
|
|
18
|
+
* Returns a release function.
|
|
19
|
+
*
|
|
20
|
+
* @param filePath - Absolute path to the file
|
|
21
|
+
* @param agentId - ID of the agent requesting the lock
|
|
22
|
+
* @returns Release function — call when done writing
|
|
23
|
+
*/
|
|
24
|
+
async acquire(filePath: string, agentId: string): Promise<() => void> {
|
|
25
|
+
// Wait for existing lock
|
|
26
|
+
while (this.locks.has(filePath)) {
|
|
27
|
+
await new Promise<void>((resolve) => {
|
|
28
|
+
const queue = this.queues.get(filePath) ?? [];
|
|
29
|
+
queue.push(resolve);
|
|
30
|
+
this.queues.set(filePath, queue);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Create lock entry
|
|
35
|
+
let releaseFn: () => void;
|
|
36
|
+
const promise = new Promise<void>((resolve) => {
|
|
37
|
+
releaseFn = () => {
|
|
38
|
+
this.locks.delete(filePath);
|
|
39
|
+
resolve();
|
|
40
|
+
// Wake next waiter
|
|
41
|
+
const queue = this.queues.get(filePath);
|
|
42
|
+
if (queue && queue.length > 0) {
|
|
43
|
+
const next = queue.shift()!;
|
|
44
|
+
next();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const entry: FileLockEntry = {
|
|
50
|
+
agentId,
|
|
51
|
+
filePath,
|
|
52
|
+
promise,
|
|
53
|
+
release: releaseFn!,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
this.locks.set(filePath, entry);
|
|
57
|
+
return releaseFn!;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if a file is currently locked.
|
|
62
|
+
*/
|
|
63
|
+
isLocked(filePath: string): boolean {
|
|
64
|
+
return this.locks.has(filePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the agent that holds a lock on a file.
|
|
69
|
+
*/
|
|
70
|
+
getHolder(filePath: string): string | undefined {
|
|
71
|
+
return this.locks.get(filePath)?.agentId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get count of locked files.
|
|
76
|
+
*/
|
|
77
|
+
get lockCount(): number {
|
|
78
|
+
return this.locks.size;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Release all locks held by an agent (on abort).
|
|
83
|
+
*/
|
|
84
|
+
releaseAll(agentId: string): void {
|
|
85
|
+
for (const [filePath, entry] of this.locks) {
|
|
86
|
+
if (entry.agentId === agentId) {
|
|
87
|
+
entry.release();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Clear all locks (on shutdown).
|
|
94
|
+
*/
|
|
95
|
+
clear(): void {
|
|
96
|
+
for (const entry of this.locks.values()) {
|
|
97
|
+
entry.release();
|
|
98
|
+
}
|
|
99
|
+
this.locks.clear();
|
|
100
|
+
this.queues.clear();
|
|
101
|
+
}
|
|
102
|
+
}
|