@youngjurry/pi-agents 0.7.1
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 +116 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/SECURITY.md +7 -0
- package/context.ts +124 -0
- package/control.ts +1332 -0
- package/index.ts +300 -0
- package/package.json +67 -0
- package/roles.ts +116 -0
- package/settings.ts +102 -0
- package/tools.ts +303 -0
- package/types.ts +161 -0
- package/viewer.ts +414 -0
package/index.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { AgentControl } from "./control.ts";
|
|
6
|
+
import { getAgentSettingsPath, loadAgentSettings, resolveAgentLimits } from "./settings.ts";
|
|
7
|
+
import { createCollaborationTools } from "./tools.ts";
|
|
8
|
+
import { EXTENSION_ID, ROOT_PATH, type AgentLifecycleStatus, type AgentView } from "./types.ts";
|
|
9
|
+
import { AgentPickerComponent, AgentTranscriptViewer } from "./viewer.ts";
|
|
10
|
+
|
|
11
|
+
const SELF_PATH = fileURLToPath(import.meta.url);
|
|
12
|
+
const WIDGET_KEY = "codex-agents-tree";
|
|
13
|
+
const STATUS_KEY = "codex-agents";
|
|
14
|
+
const PROMPT_MARKER = "<multi_agent_role>";
|
|
15
|
+
|
|
16
|
+
function statusIcon(status: AgentLifecycleStatus): string {
|
|
17
|
+
switch (status) {
|
|
18
|
+
case "queued": return "◷";
|
|
19
|
+
case "running": return "●";
|
|
20
|
+
case "completed": return "✓";
|
|
21
|
+
case "errored": return "✗";
|
|
22
|
+
case "interrupted": return "■";
|
|
23
|
+
case "pending_init": return "○";
|
|
24
|
+
case "shutdown": return "×";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function statusColor(status: AgentLifecycleStatus): "success" | "error" | "warning" | "muted" | "dim" {
|
|
29
|
+
switch (status) {
|
|
30
|
+
case "queued": return "dim";
|
|
31
|
+
case "running": return "warning";
|
|
32
|
+
case "completed": return "success";
|
|
33
|
+
case "errored": return "error";
|
|
34
|
+
case "interrupted": return "muted";
|
|
35
|
+
case "pending_init": return "dim";
|
|
36
|
+
case "shutdown": return "muted";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function treeLine(agent: AgentView, theme: Theme): string {
|
|
41
|
+
const depth = Math.max(0, agent.path.split("/").filter(Boolean).length - 1);
|
|
42
|
+
const indent = " ".repeat(depth);
|
|
43
|
+
const branch = depth > 0 ? "└─ " : "";
|
|
44
|
+
const name = agent.path === ROOT_PATH ? ROOT_PATH : agent.path.split("/").at(-1) || agent.path;
|
|
45
|
+
const icon = theme.fg(statusColor(agent.status), statusIcon(agent.status));
|
|
46
|
+
const residency = agent.status === "queued"
|
|
47
|
+
? theme.fg("warning", ` [waiting #${agent.queuePosition ?? "?"}]`)
|
|
48
|
+
: agent.path === ROOT_PATH || agent.loaded ? "" : theme.fg("dim", " [unloaded]");
|
|
49
|
+
const nickname = agent.nickname ? theme.fg("muted", ` (${agent.nickname})`) : "";
|
|
50
|
+
const runtime = agent.path === ROOT_PATH
|
|
51
|
+
? ""
|
|
52
|
+
: theme.fg("dim", ` · thinking ${agent.thinkingLevel ?? "unknown"} · ${agent.model}`);
|
|
53
|
+
return `${indent}${branch}${icon} ${theme.fg("accent", name)}${nickname}${residency}${runtime}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class AgentTreeWidget {
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly control: AgentControl,
|
|
59
|
+
private readonly getContext: () => ExtensionContext | undefined,
|
|
60
|
+
private readonly theme: Theme,
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
render(width: number): string[] {
|
|
64
|
+
const ctx = this.getContext();
|
|
65
|
+
if (!ctx) return [];
|
|
66
|
+
let agents: AgentView[];
|
|
67
|
+
try {
|
|
68
|
+
agents = this.control.list(ctx);
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const visibleAgents = agents.filter((agent) => agent.path !== ROOT_PATH && (agent.status === "running" || agent.status === "pending_init" || agent.status === "queued"));
|
|
73
|
+
if (visibleAgents.length === 0) return [];
|
|
74
|
+
const counts = this.control.getCounts();
|
|
75
|
+
const queueSummary = counts.queued > 0 ? ` · queued: ${counts.queued}` : "";
|
|
76
|
+
const lines = [
|
|
77
|
+
this.theme.fg("muted", `Agents active: ${counts.running}/${counts.slots}${queueSummary}`),
|
|
78
|
+
...visibleAgents.slice(0, 8).map((agent) => treeLine(agent, this.theme)),
|
|
79
|
+
];
|
|
80
|
+
if (visibleAgents.length > 8) lines.push(this.theme.fg("dim", ` … ${visibleAgents.length - 8} more; use /agents`));
|
|
81
|
+
return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
invalidate(): void {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default function codexAgentsExtension(pi: ExtensionAPI): void {
|
|
88
|
+
const limits = resolveAgentLimits(loadAgentSettings(), getAgentSettingsPath());
|
|
89
|
+
const control = new AgentControl(
|
|
90
|
+
pi,
|
|
91
|
+
path.resolve(SELF_PATH),
|
|
92
|
+
limits.maxConcurrentSubagents,
|
|
93
|
+
limits.maxResidentSubagents,
|
|
94
|
+
);
|
|
95
|
+
const tools = createCollaborationTools(control);
|
|
96
|
+
control.setTools(tools);
|
|
97
|
+
for (const tool of tools) pi.registerTool(tool);
|
|
98
|
+
|
|
99
|
+
let activeContext: ExtensionContext | undefined;
|
|
100
|
+
let widgetTui: { requestRender(): void } | undefined;
|
|
101
|
+
|
|
102
|
+
const updateUi = () => {
|
|
103
|
+
const ctx = activeContext;
|
|
104
|
+
if (!ctx) return;
|
|
105
|
+
let activeAgentCount = 0;
|
|
106
|
+
let queuedAgentCount = 0;
|
|
107
|
+
try {
|
|
108
|
+
const counts = control.getCounts();
|
|
109
|
+
activeAgentCount = counts.running;
|
|
110
|
+
queuedAgentCount = counts.queued;
|
|
111
|
+
} catch {
|
|
112
|
+
// Root context can be transiently unavailable during reload/shutdown.
|
|
113
|
+
}
|
|
114
|
+
ctx.ui.setStatus(
|
|
115
|
+
STATUS_KEY,
|
|
116
|
+
activeAgentCount > 0 || queuedAgentCount > 0
|
|
117
|
+
? ctx.ui.theme.fg("warning", `agents ${activeAgentCount} active${queuedAgentCount > 0 ? ` · ${queuedAgentCount} waiting` : ""}`)
|
|
118
|
+
: undefined,
|
|
119
|
+
);
|
|
120
|
+
widgetTui?.requestRender();
|
|
121
|
+
};
|
|
122
|
+
control.onChange(updateUi);
|
|
123
|
+
|
|
124
|
+
pi.on("session_start", (event, ctx) => {
|
|
125
|
+
activeContext = ctx;
|
|
126
|
+
control.bindRoot(ctx);
|
|
127
|
+
const resumedExistingSession = event.reason === "resume"
|
|
128
|
+
|| (event.reason === "startup" && ctx.sessionManager.getEntries().some((entry) => entry.type === "message"));
|
|
129
|
+
if (resumedExistingSession) {
|
|
130
|
+
const removed = control.cleanupOrphanStorage(ctx.sessionManager.getSessionId());
|
|
131
|
+
if (removed > 0) ctx.ui.notify(`Cleaned ${removed} orphaned agent storage ${removed === 1 ? "group" : "groups"}.`, "info");
|
|
132
|
+
}
|
|
133
|
+
control.configureInitialRootTools();
|
|
134
|
+
if (ctx.mode === "tui") {
|
|
135
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
136
|
+
widgetTui = tui;
|
|
137
|
+
return new AgentTreeWidget(control, () => activeContext, theme);
|
|
138
|
+
}, { placement: "belowEditor" });
|
|
139
|
+
}
|
|
140
|
+
updateUi();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
144
|
+
activeContext = ctx;
|
|
145
|
+
control.refreshRootContext(ctx);
|
|
146
|
+
if (event.systemPrompt.includes(PROMPT_MARKER)) return;
|
|
147
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${control.getRootInstructions()}` };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
151
|
+
activeContext = ctx;
|
|
152
|
+
control.setRootStatus("running");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
156
|
+
activeContext = ctx;
|
|
157
|
+
control.setRootStatus("completed");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
161
|
+
activeContext = ctx;
|
|
162
|
+
control.noteTurnStart(ROOT_PATH);
|
|
163
|
+
control.markMailboxConsumed(ROOT_PATH);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
pi.on("turn_end", (event, ctx) => {
|
|
167
|
+
activeContext = ctx;
|
|
168
|
+
control.noteTurnEnd(ROOT_PATH, event.message.role === "assistant" ? event.message.stopReason : undefined);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
pi.on("model_select", (_event, ctx) => {
|
|
172
|
+
activeContext = ctx;
|
|
173
|
+
control.refreshRootContext(ctx);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
177
|
+
activeContext = ctx;
|
|
178
|
+
await control.shutdown();
|
|
179
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
180
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
181
|
+
activeContext = undefined;
|
|
182
|
+
widgetTui = undefined;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
pi.registerMessageRenderer(EXTENSION_ID, (message, _options, theme) => {
|
|
186
|
+
const content = typeof message.content === "string"
|
|
187
|
+
? message.content
|
|
188
|
+
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
189
|
+
const messageDetails = message.details as { type?: string } | undefined;
|
|
190
|
+
if (messageDetails?.type === "AGENT_STATUS") {
|
|
191
|
+
return new Text(theme.fg("customMessageText", content), 1, 0);
|
|
192
|
+
}
|
|
193
|
+
const [typeLine = "Agent message", taskLine = "", senderLine = "", ...payload] = content.split("\n");
|
|
194
|
+
const title = typeLine.replace("Message Type: ", "");
|
|
195
|
+
const task = taskLine.replace("Task name: ", "");
|
|
196
|
+
const sender = senderLine.replace("Sender: ", "");
|
|
197
|
+
const body = payload[0] === "Payload:" ? payload.slice(1).join("\n") : payload.join("\n");
|
|
198
|
+
const header = `${theme.fg("customMessageLabel", theme.bold(title))} ${theme.fg("accent", task)} ${theme.fg("muted", `from ${sender}`)}`;
|
|
199
|
+
return new Text(`${header}\n${theme.fg("customMessageText", body)}`, 1, 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
pi.registerCommand("agents", {
|
|
203
|
+
description: "Browse sub-agent status and inspect read-only session transcripts",
|
|
204
|
+
getArgumentCompletions: (prefix) => {
|
|
205
|
+
const ctx = activeContext;
|
|
206
|
+
if (!ctx) return null;
|
|
207
|
+
const query = prefix.trim();
|
|
208
|
+
let agents: AgentView[];
|
|
209
|
+
try {
|
|
210
|
+
agents = control.list(ctx).filter((agent) => agent.path !== ROOT_PATH);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const matches = agents
|
|
215
|
+
.filter((agent) => !query || agent.path.startsWith(query))
|
|
216
|
+
.map((agent) => ({
|
|
217
|
+
value: agent.path,
|
|
218
|
+
label: agent.path,
|
|
219
|
+
description: `${agent.status} · ${agent.model} · thinking ${agent.thinkingLevel ?? "unknown"}`,
|
|
220
|
+
}));
|
|
221
|
+
return matches.length > 0 ? matches : null;
|
|
222
|
+
},
|
|
223
|
+
handler: async (args, ctx) => {
|
|
224
|
+
activeContext = ctx;
|
|
225
|
+
const requestedPath = args.trim();
|
|
226
|
+
if (ctx.mode !== "tui") {
|
|
227
|
+
if (requestedPath) {
|
|
228
|
+
try {
|
|
229
|
+
const transcript = control.transcript(ctx, requestedPath);
|
|
230
|
+
ctx.ui.notify(`${transcript.agent.path}: ${transcript.agent.status} · ${transcript.agent.model} · thinking ${transcript.agent.thinkingLevel ?? "unknown"} · ${transcript.messages.length} messages\n${transcript.sessionFile}`, "info");
|
|
231
|
+
} catch (error) {
|
|
232
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const agents = control.list(ctx).filter((agent) => agent.path !== ROOT_PATH);
|
|
237
|
+
ctx.ui.notify(agents.length > 0 ? agents.map((agent) => `${agent.path}: ${agent.status} · ${agent.model} · thinking ${agent.thinkingLevel ?? "unknown"}`).join("\n") : "No sub-agents in this root session", "info");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const releaseUserOverlay = control.beginUserOverlay();
|
|
242
|
+
try {
|
|
243
|
+
let targetPath = requestedPath || undefined;
|
|
244
|
+
while (true) {
|
|
245
|
+
if (!targetPath) {
|
|
246
|
+
targetPath = await ctx.ui.custom<string | undefined>(
|
|
247
|
+
(tui, theme, keybindings, done) => new AgentPickerComponent(
|
|
248
|
+
tui,
|
|
249
|
+
theme,
|
|
250
|
+
keybindings,
|
|
251
|
+
() => control.list(ctx),
|
|
252
|
+
(listener) => control.onChange(listener),
|
|
253
|
+
done,
|
|
254
|
+
),
|
|
255
|
+
{
|
|
256
|
+
overlay: true,
|
|
257
|
+
overlayOptions: { anchor: "center", width: "68%", maxHeight: "82%", margin: 1 },
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
if (!targetPath) return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await control.prepareTranscriptToolDefinitions(ctx);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
ctx.ui.notify(`Some custom tool renderers could not be loaded: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
control.transcript(ctx, targetPath);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
273
|
+
if (requestedPath) return;
|
|
274
|
+
targetPath = undefined;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const selectedPath = targetPath;
|
|
279
|
+
await ctx.ui.custom<void>(
|
|
280
|
+
(tui, theme, keybindings, done) => new AgentTranscriptViewer(
|
|
281
|
+
tui,
|
|
282
|
+
theme,
|
|
283
|
+
keybindings,
|
|
284
|
+
() => control.transcript(ctx, selectedPath),
|
|
285
|
+
(listener) => control.onChange(listener),
|
|
286
|
+
done,
|
|
287
|
+
),
|
|
288
|
+
{
|
|
289
|
+
overlay: true,
|
|
290
|
+
overlayOptions: { anchor: "center", width: "92%", maxHeight: "92%", margin: 1 },
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
targetPath = undefined;
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
releaseUserOverlay();
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@youngjurry/pi-agents",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Persistent in-process Codex-style multi-agent collaboration for Pi",
|
|
5
|
+
"author": "youngjurry",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"multi-agent",
|
|
12
|
+
"subagent",
|
|
13
|
+
"codex"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/YoungJurry/pi-agents.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/YoungJurry/pi-agents#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/YoungJurry/pi-agents/issues"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"check": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "tsx --test test/*.test.ts",
|
|
26
|
+
"prepublishOnly": "npm run check && npm test"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"*.ts",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"SECURITY.md",
|
|
33
|
+
"CHANGELOG.md"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": "*",
|
|
42
|
+
"@earendil-works/pi-agent-core": "*",
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*",
|
|
45
|
+
"typebox": "*"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"@earendil-works/pi-ai": { "optional": true },
|
|
49
|
+
"@earendil-works/pi-agent-core": { "optional": true },
|
|
50
|
+
"@earendil-works/pi-coding-agent": { "optional": true },
|
|
51
|
+
"@earendil-works/pi-tui": { "optional": true },
|
|
52
|
+
"typebox": { "optional": true }
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@earendil-works/pi-ai": "^0.84.4",
|
|
56
|
+
"@earendil-works/pi-agent-core": "^0.84.4",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
58
|
+
"@earendil-works/pi-tui": "^0.84.4",
|
|
59
|
+
"@types/node": "^26.4.0",
|
|
60
|
+
"tsx": "^4.23.13",
|
|
61
|
+
"typebox": "^1.3.7",
|
|
62
|
+
"typescript": "^7.0.2"
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/roles.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { AgentRole } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const BUILTIN_ROLES: AgentRole[] = [
|
|
8
|
+
{
|
|
9
|
+
name: "default",
|
|
10
|
+
description: "General-purpose coding agent",
|
|
11
|
+
systemPrompt: "",
|
|
12
|
+
source: "builtin",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "explorer",
|
|
16
|
+
description: "Read-only codebase exploration and focused research",
|
|
17
|
+
systemPrompt: "Explore the codebase efficiently. Do not modify files. Return concise findings with exact paths and relevant implementation details.",
|
|
18
|
+
tools: ["read", "bash", "grep", "find", "ls"],
|
|
19
|
+
source: "builtin",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "awaiter",
|
|
23
|
+
description: "Wait for long-running commands or tasks and report completion",
|
|
24
|
+
systemPrompt: "Wait conservatively for the assigned command or task to reach a terminal state. Do not modify or optimize it. Use long waits, do not hallucinate completion, and report the final status only when known.",
|
|
25
|
+
thinkingLevel: "low",
|
|
26
|
+
source: "builtin",
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
type RoleFrontmatter = {
|
|
31
|
+
name?: unknown;
|
|
32
|
+
description?: unknown;
|
|
33
|
+
tools?: unknown;
|
|
34
|
+
model?: unknown;
|
|
35
|
+
thinking?: unknown;
|
|
36
|
+
nickname_candidates?: unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
40
|
+
|
|
41
|
+
function stringList(value: unknown): string[] | undefined {
|
|
42
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
43
|
+
const result = values.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
44
|
+
return result.length > 0 ? result : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadDirectory(directory: string, source: "user" | "project"): AgentRole[] {
|
|
48
|
+
if (!fs.existsSync(directory)) return [];
|
|
49
|
+
let entries: fs.Dirent[];
|
|
50
|
+
try {
|
|
51
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const roles: AgentRole[] = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (!entry.name.endsWith(".md") || (!entry.isFile() && !entry.isSymbolicLink())) continue;
|
|
58
|
+
try {
|
|
59
|
+
const content = fs.readFileSync(path.join(directory, entry.name), "utf8");
|
|
60
|
+
const { frontmatter, body } = parseFrontmatter<RoleFrontmatter>(content);
|
|
61
|
+
if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") continue;
|
|
62
|
+
const thinking = typeof frontmatter.thinking === "string" && THINKING_LEVELS.has(frontmatter.thinking as ThinkingLevel)
|
|
63
|
+
? frontmatter.thinking as ThinkingLevel
|
|
64
|
+
: undefined;
|
|
65
|
+
roles.push({
|
|
66
|
+
name: frontmatter.name.trim(),
|
|
67
|
+
description: frontmatter.description.trim(),
|
|
68
|
+
systemPrompt: body.trim(),
|
|
69
|
+
tools: stringList(frontmatter.tools),
|
|
70
|
+
model: typeof frontmatter.model === "string" ? frontmatter.model.trim() : undefined,
|
|
71
|
+
thinkingLevel: thinking,
|
|
72
|
+
nicknameCandidates: stringList(frontmatter.nickname_candidates),
|
|
73
|
+
source,
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
// A malformed role must not prevent other roles from loading.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return roles;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nearestProjectRoles(cwd: string): string | undefined {
|
|
83
|
+
let current = path.resolve(cwd);
|
|
84
|
+
while (true) {
|
|
85
|
+
const candidate = path.join(current, CONFIG_DIR_NAME, "agents");
|
|
86
|
+
try {
|
|
87
|
+
if (fs.statSync(candidate).isDirectory()) return candidate;
|
|
88
|
+
} catch {
|
|
89
|
+
// Continue walking.
|
|
90
|
+
}
|
|
91
|
+
const parent = path.dirname(current);
|
|
92
|
+
if (parent === current) return undefined;
|
|
93
|
+
current = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function discoverRoles(cwd: string, projectTrusted: boolean): AgentRole[] {
|
|
98
|
+
const roles = new Map<string, AgentRole>();
|
|
99
|
+
for (const role of BUILTIN_ROLES) roles.set(role.name, role);
|
|
100
|
+
for (const role of loadDirectory(path.join(getAgentDir(), "agents"), "user")) roles.set(role.name, role);
|
|
101
|
+
const projectDirectory = nearestProjectRoles(cwd);
|
|
102
|
+
if (projectTrusted && projectDirectory) {
|
|
103
|
+
for (const role of loadDirectory(projectDirectory, "project")) roles.set(role.name, role);
|
|
104
|
+
}
|
|
105
|
+
return [...roles.values()];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveRole(cwd: string, projectTrusted: boolean, name?: string): AgentRole {
|
|
109
|
+
const roleName = name?.trim() || "default";
|
|
110
|
+
const role = discoverRoles(cwd, projectTrusted).find((candidate) => candidate.name === roleName);
|
|
111
|
+
if (!role) {
|
|
112
|
+
const names = discoverRoles(cwd, projectTrusted).map((candidate) => candidate.name).join(", ");
|
|
113
|
+
throw new Error(`unknown agent_type '${roleName}'. Available roles: ${names}`);
|
|
114
|
+
}
|
|
115
|
+
return role;
|
|
116
|
+
}
|
package/settings.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import {
|
|
5
|
+
clampThinkingLevel,
|
|
6
|
+
getSupportedThinkingLevels,
|
|
7
|
+
type Model,
|
|
8
|
+
} from "@earendil-works/pi-ai";
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_MAX_CONCURRENT_SUBAGENTS = 3;
|
|
12
|
+
export const DEFAULT_MAX_RESIDENT_SUBAGENTS = 3;
|
|
13
|
+
export const DEFAULT_CHILD_THINKING_LEVEL: ThinkingLevel = "medium";
|
|
14
|
+
export const CHILD_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
15
|
+
|
|
16
|
+
export interface AgentSettings {
|
|
17
|
+
defaultModel?: string;
|
|
18
|
+
defaultThinkingLevel?: ThinkingLevel;
|
|
19
|
+
maxConcurrentSubagents?: number;
|
|
20
|
+
maxResidentSubagents?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AgentLimits {
|
|
24
|
+
maxConcurrentSubagents: number;
|
|
25
|
+
maxResidentSubagents: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getAgentSettingsPath(): string {
|
|
29
|
+
return path.join(getAgentDir(), "codex-agents", "agents-setting.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function selectAgentModel(...candidates: Array<string | undefined>): string | undefined {
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
const model = candidate?.trim();
|
|
35
|
+
if (model) return model;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function selectAgentThinkingLevel(
|
|
41
|
+
model: Model<any>,
|
|
42
|
+
explicitModel: string | undefined,
|
|
43
|
+
explicitThinking: ThinkingLevel | undefined,
|
|
44
|
+
roleThinking: ThinkingLevel | undefined,
|
|
45
|
+
globalThinking: ThinkingLevel | undefined,
|
|
46
|
+
): ThinkingLevel {
|
|
47
|
+
if (explicitThinking !== undefined) return clampThinkingLevel(model, explicitThinking) as ThinkingLevel;
|
|
48
|
+
if (explicitModel?.trim()) {
|
|
49
|
+
const levels = getSupportedThinkingLevels(model);
|
|
50
|
+
return (levels.at(-1) ?? "off") as ThinkingLevel;
|
|
51
|
+
}
|
|
52
|
+
return clampThinkingLevel(model, roleThinking ?? globalThinking ?? DEFAULT_CHILD_THINKING_LEVEL) as ThinkingLevel;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadAgentSettings(filePath = getAgentSettingsPath()): AgentSettings {
|
|
56
|
+
if (!fs.existsSync(filePath)) return {};
|
|
57
|
+
|
|
58
|
+
let value: unknown;
|
|
59
|
+
try {
|
|
60
|
+
value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
throw new Error(`failed to read agent settings at ${filePath}: ${message}`);
|
|
64
|
+
}
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
66
|
+
throw new Error(`agent settings at ${filePath} must contain a JSON object`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const raw = value as Record<string, unknown>;
|
|
70
|
+
const settings: AgentSettings = {};
|
|
71
|
+
if (raw.defaultModel !== undefined) {
|
|
72
|
+
if (typeof raw.defaultModel !== "string" || !raw.defaultModel.trim()) {
|
|
73
|
+
throw new Error(`defaultModel in ${filePath} must be a non-empty provider/model string`);
|
|
74
|
+
}
|
|
75
|
+
settings.defaultModel = raw.defaultModel.trim();
|
|
76
|
+
}
|
|
77
|
+
if (raw.defaultThinkingLevel !== undefined) {
|
|
78
|
+
if (typeof raw.defaultThinkingLevel !== "string" || !CHILD_THINKING_LEVELS.includes(raw.defaultThinkingLevel as ThinkingLevel)) {
|
|
79
|
+
throw new Error(`defaultThinkingLevel in ${filePath} must be one of: ${CHILD_THINKING_LEVELS.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
settings.defaultThinkingLevel = raw.defaultThinkingLevel as ThinkingLevel;
|
|
82
|
+
}
|
|
83
|
+
for (const key of ["maxConcurrentSubagents", "maxResidentSubagents"] as const) {
|
|
84
|
+
const limit = raw[key];
|
|
85
|
+
if (limit === undefined) continue;
|
|
86
|
+
if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1) {
|
|
87
|
+
throw new Error(`${key} in ${filePath} must be a positive integer`);
|
|
88
|
+
}
|
|
89
|
+
settings[key] = limit;
|
|
90
|
+
}
|
|
91
|
+
return settings;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveAgentLimits(settings: AgentSettings, filePath = getAgentSettingsPath()): AgentLimits {
|
|
95
|
+
const maxConcurrentSubagents = settings.maxConcurrentSubagents ?? DEFAULT_MAX_CONCURRENT_SUBAGENTS;
|
|
96
|
+
const maxResidentSubagents = settings.maxResidentSubagents
|
|
97
|
+
?? Math.max(DEFAULT_MAX_RESIDENT_SUBAGENTS, maxConcurrentSubagents);
|
|
98
|
+
if (maxResidentSubagents < maxConcurrentSubagents) {
|
|
99
|
+
throw new Error(`maxResidentSubagents in ${filePath} must be greater than or equal to maxConcurrentSubagents`);
|
|
100
|
+
}
|
|
101
|
+
return { maxConcurrentSubagents, maxResidentSubagents };
|
|
102
|
+
}
|