@bermudi/pi-delegate 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/README.md +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# pi-delegate
|
|
2
|
+
|
|
3
|
+
Repository: https://github.com/bermudi/pi-delegate
|
|
4
|
+
|
|
5
|
+
Delegate tool for the [Pi coding agent](https://github.com/earendil-works/pi) — spawn
|
|
6
|
+
subagents to run tasks in parallel, with async ticketing, session pooling, retries,
|
|
7
|
+
and per-model concurrency limits.
|
|
8
|
+
|
|
9
|
+
Extracted from [`bermudi/agent-extensions`](https://github.com/bermudi/agent-extensions)
|
|
10
|
+
as a standalone repo (full history preserved).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Symlink the **bundled** output into Pi's global extensions dir:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
ln -s "$PWD/delegate.bundle.ts" ~/.pi/agent/extensions/delegate.ts
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then `/reload` in Pi.
|
|
21
|
+
|
|
22
|
+
### Token accounting
|
|
23
|
+
|
|
24
|
+
Sync delegate calls report aggregate subagent `Usage` on the tool result, so Pi
|
|
25
|
+
(0.81+) folds those tokens **and cost** into the parent footer and session
|
|
26
|
+
total automatically — no manual addition needed.
|
|
27
|
+
|
|
28
|
+
Async tickets can't be auto-counted: their results arrive as a follow-up message,
|
|
29
|
+
which has no usage slot. The per-call aggregate (`Nk tokens`) is still shown in
|
|
30
|
+
the delegate header for both modes. Use sync delegation when totals must roll
|
|
31
|
+
into the session.
|
|
32
|
+
|
|
33
|
+
### Stall detection and cancellation
|
|
34
|
+
|
|
35
|
+
`stallTimeoutMs` is an inactivity watchdog, not a hard execution deadline. When
|
|
36
|
+
an active subagent emits no model or tool activity for the configured interval,
|
|
37
|
+
delegate reports that a stall was detected and asks Pi's `AgentSession.abort()`
|
|
38
|
+
to cancel it, along with any active compaction or branch summary. Cancellation
|
|
39
|
+
is cooperative: delegate waits for the session to become idle and non-compacting
|
|
40
|
+
before returning a failed task, because returning while a provider, tool, or
|
|
41
|
+
extension can still run would let a supposedly finished agent keep mutating
|
|
42
|
+
state. An operation that ignores cancellation can therefore delay the final
|
|
43
|
+
task result. Set `stallTimeoutMs` to `0` to disable the watchdog.
|
|
44
|
+
|
|
45
|
+
## Develop
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bun install
|
|
49
|
+
bun run build # regenerate delegate.bundle.ts
|
|
50
|
+
bun run typecheck
|
|
51
|
+
bun test
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The unbundled entry is `delegate.ts`; `extension.ts` holds the tool implementation.
|
|
55
|
+
`delegate.bundle.ts` is generated by `bun run build` — do not edit by hand.
|
|
56
|
+
|
|
57
|
+
## Glossary
|
|
58
|
+
|
|
59
|
+
- **Delegate task** — One item in `delegate({ tasks: [...] })`. This is the
|
|
60
|
+
core unit of work: a prompt plus optional overrides such as `agent`, `tools`,
|
|
61
|
+
`systemPrompt`, `thinking`, `cwd`, `context`, `sessionId`, or `resumeFrom`.
|
|
62
|
+
`model` is also accepted but should be rare — subagents inherit the parent
|
|
63
|
+
model by default.
|
|
64
|
+
- **Custom agent** — A subagent profile defined by the parent, either inline in
|
|
65
|
+
a delegate task (`systemPrompt`, `tools`, and `thinking`) or persisted as a
|
|
66
|
+
Markdown file. The subagent inherits the parent model by default; `model` is a
|
|
67
|
+
rare override. Markdown agents are examples of custom agents.
|
|
68
|
+
- **Named agent** / **Markdown agent** — A reusable custom agent persisted as a
|
|
69
|
+
Markdown file in `.pi/agents/*.md` or `~/.pi/agent/agents/*.md`. The frontmatter
|
|
70
|
+
defines its name, description, model, tools, thinking level, and skills; the
|
|
71
|
+
Markdown body is its system prompt.
|
|
72
|
+
- **Ad-hoc subagent** — A subagent created from inline task fields instead of a
|
|
73
|
+
named Markdown agent profile. In current output this is labeled `ad-hoc`.
|
|
74
|
+
- **Inline task** — The task object itself when its configuration is supplied
|
|
75
|
+
directly in the delegate call. Prefer this term over “inline agent” when
|
|
76
|
+
talking about the API shape.
|
|
77
|
+
- **Subagent run** — The actual execution of a resolved delegate task. Multiple
|
|
78
|
+
runs using the same named agent are independent unless they share a
|
|
79
|
+
`sessionId` or resume from the same session file.
|
|
80
|
+
- **Pooled subagent** / **persistent session** — A live subagent kept in memory
|
|
81
|
+
under a `sessionId`. The first call creates it; later calls with the same
|
|
82
|
+
`sessionId` continue the same conversation until explicitly closed or the parent Pi session ends.
|
|
83
|
+
- **Resumed subagent** — A subagent rehydrated from a previous session `.jsonl`
|
|
84
|
+
via `resumeFrom`. It can also be pooled by providing a `sessionId`.
|
|
85
|
+
- **Async ticket** — A background execution handle returned when top-level
|
|
86
|
+
`async: true` is used. Poll or cancel tickets with top-level `action: "poll"`
|
|
87
|
+
or `action: "cancel"`.
|
|
88
|
+
- **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
|
|
89
|
+
prompt. Skills are text instructions only; they do not unlock additional
|
|
90
|
+
tools.
|
|
91
|
+
- **AGENTS.md context** — Project and global guidance files automatically
|
|
92
|
+
appended to subagent system prompts, separate from named agent definitions.
|
package/agents.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
AgentMessage,
|
|
6
|
+
ThinkingLevel,
|
|
7
|
+
} from "@earendil-works/pi-agent-core";
|
|
8
|
+
import { parseFrontmatter as parsePiFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { DEFAULT_TOOLS, VALID_THINKING } from "./constants.ts";
|
|
10
|
+
import { resolveToolGroups } from "./tools.ts";
|
|
11
|
+
import type { AgentConfig } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
// Frontmatter fence: `---\n … \n---\n body`. CRLF-tolerant. Captures the
|
|
14
|
+
// YAML block (group 1) and the body (group 2). The YAML itself is parsed by
|
|
15
|
+
// pi-coding-agent's `parseFrontmatter` (built on the `yaml` package, which is
|
|
16
|
+
// a guaranteed dependency of the host pi) — we only use this regex to split
|
|
17
|
+
// the fence from the body and to detect the no-frontmatter case.
|
|
18
|
+
const FRONTMATTER_FENCE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
19
|
+
|
|
20
|
+
/** Coerce a parsed YAML frontmatter object into the flat
|
|
21
|
+
* `Record<string, string>` shape the rest of the loader expects. Arrays
|
|
22
|
+
* (e.g. `tools: [read, write]`) are joined with ", " so the downstream
|
|
23
|
+
* comma-split in `resolveFrontmatterTools` still works. Nested maps are
|
|
24
|
+
* JSON-stringified rather than `String()`-ified (which would emit the
|
|
25
|
+
* useless "[object Object]"). `null`/`undefined` are dropped. The agent
|
|
26
|
+
* frontmatter schema is flat by convention, so the object branch only fires
|
|
27
|
+
* on malformed input and keeps it debuggable instead of silently garbage. */
|
|
28
|
+
function frontmatterToData(
|
|
29
|
+
fm: Record<string, unknown>,
|
|
30
|
+
): Record<string, string> {
|
|
31
|
+
const data: Record<string, string> = {};
|
|
32
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
33
|
+
if (v === null || v === undefined) continue;
|
|
34
|
+
if (Array.isArray(v)) data[k] = v.map(String).join(", ");
|
|
35
|
+
else if (typeof v === "object") data[k] = JSON.stringify(v);
|
|
36
|
+
else data[k] = String(v);
|
|
37
|
+
}
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Quote ambiguous YAML scalar values before handing them to Pi's parser. */
|
|
42
|
+
function sanitizeYamlScalars(yaml: string): string {
|
|
43
|
+
return yaml
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map((line) => {
|
|
46
|
+
const m = line.match(/^(\s*)([\w-]+)(\s*:\s*)(.*)$/);
|
|
47
|
+
if (!m) return line;
|
|
48
|
+
const [, leading, key, sep, rawValue] = m;
|
|
49
|
+
const value = rawValue.trim();
|
|
50
|
+
if (!value) return line;
|
|
51
|
+
if (/^["'|>\[{]/.test(value)) return line;
|
|
52
|
+
if (!/:\s/.test(value)) return line;
|
|
53
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
54
|
+
return `${leading}${key}${sep}"${escaped}"`;
|
|
55
|
+
})
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse an agent Markdown frontmatter fence and return its body. */
|
|
60
|
+
export function parseFrontmatter(
|
|
61
|
+
content: string,
|
|
62
|
+
filePath?: string,
|
|
63
|
+
): {
|
|
64
|
+
data: Record<string, string>;
|
|
65
|
+
body: string;
|
|
66
|
+
} {
|
|
67
|
+
const m = content.match(FRONTMATTER_FENCE);
|
|
68
|
+
if (!m) return { data: {}, body: content.trim() };
|
|
69
|
+
const yamlString = m[1]!;
|
|
70
|
+
const body = m[2]!.trim();
|
|
71
|
+
|
|
72
|
+
// A bare `*` is a YAML alias indicator and is invalid as a scalar, so
|
|
73
|
+
// `tools: *` (the full-agent shorthand) would throw. Quote any value that is
|
|
74
|
+
// exactly `*` so it parses as the string "*", which resolveFrontmatterTools
|
|
75
|
+
// then expands via TOOL_GROUPS. (A `*` mid-scalar, e.g. `use * here`, is a
|
|
76
|
+
// legal plain scalar and needs no quoting.)
|
|
77
|
+
const sanitized = sanitizeYamlScalars(
|
|
78
|
+
yamlString.replace(/^(\s*[\w-]+):\s*\*(?=\s*$)/gm, '$1: "*"'),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Re-wrap and let pi's parser (yaml under the hood) do the real work.
|
|
83
|
+
const { frontmatter, body: parsedBody } = parsePiFrontmatter(
|
|
84
|
+
`---\n${sanitized}\n---\n${body}`,
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
data: frontmatterToData((frontmatter ?? {}) as Record<string, unknown>),
|
|
88
|
+
body: parsedBody,
|
|
89
|
+
};
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// Malformed frontmatter is a user error, not a crash. Log a clear,
|
|
92
|
+
// actionable message (with the file path when available) and return empty
|
|
93
|
+
// data so the caller's `!data.name || !data.description` check skips the
|
|
94
|
+
// file rather than importing a half-parsed agent.
|
|
95
|
+
const where = filePath ? ` (${filePath})` : "";
|
|
96
|
+
console.warn(
|
|
97
|
+
`[delegate] malformed agent frontmatter${where}: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`,
|
|
98
|
+
);
|
|
99
|
+
return { data: {}, body: content.trim() };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Agent Discovery ───────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** Find the nearest ancestor containing project-scoped agent files. */
|
|
106
|
+
export function findProjectRoot(cwd: string): string | null {
|
|
107
|
+
let dir = cwd;
|
|
108
|
+
while (true) {
|
|
109
|
+
// A project root is any dir hosting a pi-native or Claude agent dir.
|
|
110
|
+
// Recognizing .claude/agents here means a project using only Claude Code's
|
|
111
|
+
// convention still resolves its project-scoped agents.
|
|
112
|
+
if (
|
|
113
|
+
fs.existsSync(path.join(dir, ".pi", "agents")) ||
|
|
114
|
+
fs.existsSync(path.join(dir, ".claude", "agents"))
|
|
115
|
+
)
|
|
116
|
+
return dir;
|
|
117
|
+
const parent = path.dirname(dir);
|
|
118
|
+
if (parent === dir) return null;
|
|
119
|
+
dir = parent;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Map Claude Code's capitalized tool names to delegate's lowercase set.
|
|
124
|
+
* Unmappable tools (WebSearch, WebFetch, TodoWrite, …) are dropped — they are
|
|
125
|
+
* not delegate tools, and warning per imported agent would be noise. The map
|
|
126
|
+
* is exported for tests; do not call it for native pi frontmatter. */
|
|
127
|
+
const CLAUDE_TOOL_ALIASES: Record<string, string> = {
|
|
128
|
+
read: "read",
|
|
129
|
+
write: "write",
|
|
130
|
+
edit: "edit",
|
|
131
|
+
bash: "bash",
|
|
132
|
+
glob: "find",
|
|
133
|
+
grep: "grep",
|
|
134
|
+
ls: "ls",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Parse a `tools:` frontmatter value into a resolved tool list.
|
|
138
|
+
* Omitted or blank → inherit the full agent set (`*`), matching CC/OpenCode/
|
|
139
|
+
* Devin convention. This is the only caller-owned knob — both inline tasks
|
|
140
|
+
* and named agents now inherit-all when `tools:` is absent. */
|
|
141
|
+
function resolveFrontmatterTools(
|
|
142
|
+
raw: string | undefined,
|
|
143
|
+
aliasMap?: Record<string, string>,
|
|
144
|
+
): string[] {
|
|
145
|
+
if (!raw) return DEFAULT_TOOLS; // omitted/blank → inherit *
|
|
146
|
+
const names = raw
|
|
147
|
+
.split(",")
|
|
148
|
+
.map((s) => s.trim())
|
|
149
|
+
.filter(Boolean);
|
|
150
|
+
if (!names.length) return DEFAULT_TOOLS;
|
|
151
|
+
const mapped = aliasMap
|
|
152
|
+
? names
|
|
153
|
+
.map((n) => aliasMap[n.toLowerCase()] ?? null)
|
|
154
|
+
.filter((n): n is string => n !== null)
|
|
155
|
+
: names;
|
|
156
|
+
// Empty after aliasing (e.g. a Claude agent listing only WebSearch) → inherit.
|
|
157
|
+
return mapped.length ? resolveToolGroups(mapped) : DEFAULT_TOOLS;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Parse and alias a comma-separated Claude tool list into delegate tool names,
|
|
161
|
+
* WITHOUT inheriting on empty. Returns the mapped names only (may be empty).
|
|
162
|
+
* Used when the result will be subtracted from a base set (disallowedTools). */
|
|
163
|
+
function mapClaudeToolNames(raw: string | undefined): string[] {
|
|
164
|
+
if (!raw) return [];
|
|
165
|
+
return raw
|
|
166
|
+
.split(",")
|
|
167
|
+
.map((s) => s.trim())
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
.map((n) => CLAUDE_TOOL_ALIASES[n.toLowerCase()] ?? null)
|
|
170
|
+
.filter((n): n is string => n !== null);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Load a native Pi agent Markdown file, or null when it is invalid. */
|
|
174
|
+
export function loadAgentFile(filePath: string): AgentConfig | null {
|
|
175
|
+
let content: string;
|
|
176
|
+
try {
|
|
177
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const { data, body } = parseFrontmatter(content, filePath);
|
|
182
|
+
if (!data.name || !data.description) return null;
|
|
183
|
+
return {
|
|
184
|
+
name: data.name,
|
|
185
|
+
description: data.description,
|
|
186
|
+
model: data.model,
|
|
187
|
+
thinking: VALID_THINKING.has(data.thinking ?? "")
|
|
188
|
+
? (data.thinking as ThinkingLevel)
|
|
189
|
+
: "off",
|
|
190
|
+
// Omitted/blank `tools:` → inherit the full agent set (`*`), matching
|
|
191
|
+
// CC/OpenCode/Devin. A previous version rejected empty tools; that was
|
|
192
|
+
// stricter than every comparable tool and surprised anyone porting a
|
|
193
|
+
// profile. Inline tasks still get `[]` as a deliberate escape hatch —
|
|
194
|
+
// this code path only governs named-agent file loading.
|
|
195
|
+
tools: resolveFrontmatterTools(data.tools),
|
|
196
|
+
systemPrompt: body,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Variant for `.claude/agents/*.md` files. Two Claude-specific adaptations:
|
|
201
|
+
* - Maps capitalized tool names (Read/Glob/…) to delegate tools, dropping
|
|
202
|
+
* unmappable ones (WebSearch, TodoWrite, …). Omitted `tools` inherits `*`.
|
|
203
|
+
* - Honors `disallowedTools` as a denylist layered on top of the resolved
|
|
204
|
+
* set (Claude semantics: denylist applies whether or not an allowlist is
|
|
205
|
+
* set). Since delegate has no runtime denylist, we bake it into `tools` at
|
|
206
|
+
* import time. A reviewer with `disallowedTools: Write, Edit` and no
|
|
207
|
+
* `tools` becomes `read, bash, grep, find, ls` — it does NOT silently
|
|
208
|
+
* inherit full tools.
|
|
209
|
+
* - `model: inherit` (Claude's default) is mapped to "omit" so the agent
|
|
210
|
+
* inherits the parent model; passing it through verbatim would crash
|
|
211
|
+
* resolveModel() with "model 'inherit' is not available". */
|
|
212
|
+
export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
|
|
213
|
+
let content: string;
|
|
214
|
+
try {
|
|
215
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const { data, body } = parseFrontmatter(content, filePath);
|
|
220
|
+
if (!data.name || !data.description) return null;
|
|
221
|
+
|
|
222
|
+
let tools = resolveFrontmatterTools(data.tools, CLAUDE_TOOL_ALIASES);
|
|
223
|
+
// disallowedTools is a denylist applied after the allowlist resolves.
|
|
224
|
+
// Empty-after-mapping denylist (e.g. only unmappable names) → no-op.
|
|
225
|
+
const denied = new Set(mapClaudeToolNames(data.disallowedTools));
|
|
226
|
+
if (denied.size) tools = tools.filter((t) => !denied.has(t));
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
name: data.name,
|
|
230
|
+
description: data.description,
|
|
231
|
+
// `inherit` is Claude's "use parent" default — drop it so we fall through
|
|
232
|
+
// to parent-model inheritance. Any other value passes through verbatim.
|
|
233
|
+
model:
|
|
234
|
+
data.model && data.model.toLowerCase() === "inherit"
|
|
235
|
+
? undefined
|
|
236
|
+
: data.model,
|
|
237
|
+
thinking: VALID_THINKING.has(data.thinking ?? "")
|
|
238
|
+
? (data.thinking as ThinkingLevel)
|
|
239
|
+
: "off",
|
|
240
|
+
tools,
|
|
241
|
+
systemPrompt: body,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Discover native and Claude-compatible agents in priority order. */
|
|
246
|
+
export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
247
|
+
// Discovery order for persisted Markdown agents (first definition wins;
|
|
248
|
+
// later dirs cannot overwrite):
|
|
249
|
+
// 1. project .pi/agents (highest priority)
|
|
250
|
+
// 2. global ~/.pi/agent/agents
|
|
251
|
+
// 3. global ~/.agents (legacy)
|
|
252
|
+
// 4. project .claude/agents (Claude Code interchange)
|
|
253
|
+
// 5. global ~/.claude/agents
|
|
254
|
+
//
|
|
255
|
+
// Custom agents are defined by the parent either inline in a task or as a
|
|
256
|
+
// Markdown file in one of the directories above. Markdown agents are
|
|
257
|
+
// examples of custom agents; the parent model can shape the subagent it
|
|
258
|
+
// needs on each call.
|
|
259
|
+
const projectRoot = findProjectRoot(cwd);
|
|
260
|
+
const nativeDirs: { dir: string; scope: "project" | "global" }[] = [];
|
|
261
|
+
if (projectRoot)
|
|
262
|
+
nativeDirs.push({
|
|
263
|
+
dir: path.join(projectRoot, ".pi", "agents"),
|
|
264
|
+
scope: "project",
|
|
265
|
+
});
|
|
266
|
+
// Global user agents — same convention as skills, AGENTS.md, and pi-subagents
|
|
267
|
+
nativeDirs.push({
|
|
268
|
+
dir: path.join(os.homedir(), ".pi", "agent", "agents"),
|
|
269
|
+
scope: "global",
|
|
270
|
+
});
|
|
271
|
+
nativeDirs.push({ dir: path.join(os.homedir(), ".agents"), scope: "global" }); // legacy
|
|
272
|
+
|
|
273
|
+
const claudeDirs: { dir: string; scope: "claude" }[] = [];
|
|
274
|
+
if (projectRoot)
|
|
275
|
+
claudeDirs.push({
|
|
276
|
+
dir: path.join(projectRoot, ".claude", "agents"),
|
|
277
|
+
scope: "claude",
|
|
278
|
+
});
|
|
279
|
+
claudeDirs.push({
|
|
280
|
+
dir: path.join(os.homedir(), ".claude", "agents"),
|
|
281
|
+
scope: "claude",
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const agents = new Map<string, AgentConfig>();
|
|
285
|
+
const loadDir = (
|
|
286
|
+
{ dir, scope }: { dir: string; scope: AgentConfig["scope"] },
|
|
287
|
+
loader: (fp: string) => AgentConfig | null,
|
|
288
|
+
) => {
|
|
289
|
+
let entries: fs.Dirent[];
|
|
290
|
+
try {
|
|
291
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
292
|
+
} catch {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
for (const e of entries) {
|
|
296
|
+
if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
|
|
297
|
+
const cfg = loader(path.join(dir, e.name));
|
|
298
|
+
if (cfg && !agents.has(cfg.name)) {
|
|
299
|
+
cfg.scope = scope;
|
|
300
|
+
agents.set(cfg.name, cfg);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
for (const d of nativeDirs) loadDir(d, loadAgentFile);
|
|
306
|
+
for (const d of claudeDirs) loadDir(d, loadClaudeAgentFile);
|
|
307
|
+
|
|
308
|
+
return agents;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Subagent Prompt Assembly ──────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
export const DEFAULT_SUBAGENT_SYSTEM_PROMPT =
|
|
314
|
+
"You are a helpful coding assistant.";
|
|
315
|
+
|
|
316
|
+
function firstNonBlank(
|
|
317
|
+
...values: Array<string | undefined>
|
|
318
|
+
): string | undefined {
|
|
319
|
+
return values.find(
|
|
320
|
+
(v): v is string => typeof v === "string" && v.trim().length > 0,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Select the frozen, task, agent, or parent prompt for a subagent. */
|
|
325
|
+
export function buildSubagentSystemPrompt(options: {
|
|
326
|
+
taskSystemPrompt?: string;
|
|
327
|
+
agentSystemPrompt?: string;
|
|
328
|
+
parentSystemPrompt?: string;
|
|
329
|
+
pooledSystemPrompt?: string;
|
|
330
|
+
}): string {
|
|
331
|
+
// Pooled agents already have a frozen prompt baked into their session state.
|
|
332
|
+
// Return it unchanged so repeated sessionId calls do not re-resolve.
|
|
333
|
+
if (options.pooledSystemPrompt?.trim()) return options.pooledSystemPrompt;
|
|
334
|
+
|
|
335
|
+
// Return only the base prompt. AgentSession constructs the full system prompt
|
|
336
|
+
// from this custom prompt + its own resource-loader discovery (skills,
|
|
337
|
+
// AGENTS.md, active-tool snippets). We previously appended skills/AGENTS.md
|
|
338
|
+
// here; that duplicated AgentSession's work.
|
|
339
|
+
const base =
|
|
340
|
+
firstNonBlank(
|
|
341
|
+
options.taskSystemPrompt,
|
|
342
|
+
options.agentSystemPrompt,
|
|
343
|
+
options.parentSystemPrompt,
|
|
344
|
+
) ?? DEFAULT_SUBAGENT_SYSTEM_PROMPT;
|
|
345
|
+
|
|
346
|
+
return base;
|
|
347
|
+
}
|
package/concurrency.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getMaxConcurrent } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
// ── Module-level global concurrency cap ───────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// `maxConcurrent` is a hard ceiling on the *total* number of subagent tasks
|
|
7
|
+
// that may run at once across the entire extension, not per `delegate` call.
|
|
8
|
+
// A single shared semaphore makes that guarantee real: multiple concurrent
|
|
9
|
+
// sync/async `delegate` invocations contend for the same pool of slots.
|
|
10
|
+
|
|
11
|
+
let globalConcurrencyLimit = Math.max(1, getMaxConcurrent());
|
|
12
|
+
let globalConcurrencyRunning = 0;
|
|
13
|
+
const globalConcurrencyWaiters: Array<() => void> = [];
|
|
14
|
+
|
|
15
|
+
function acquireGlobal(): Promise<void> {
|
|
16
|
+
if (globalConcurrencyRunning < globalConcurrencyLimit) {
|
|
17
|
+
globalConcurrencyRunning++;
|
|
18
|
+
return Promise.resolve();
|
|
19
|
+
}
|
|
20
|
+
return new Promise<void>((r) => globalConcurrencyWaiters.push(r));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function releaseGlobal(): void {
|
|
24
|
+
globalConcurrencyRunning--;
|
|
25
|
+
if (globalConcurrencyWaiters.length > 0) {
|
|
26
|
+
globalConcurrencyRunning++;
|
|
27
|
+
globalConcurrencyWaiters.shift()!();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Test-only hook: override the global concurrency cap. */
|
|
32
|
+
export function _setGlobalConcurrencyLimitForTesting(limit: number): void {
|
|
33
|
+
globalConcurrencyLimit = Math.max(1, limit);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Test-only hook: reset the global semaphore to the configured cap. */
|
|
37
|
+
export function _resetGlobalConcurrencyForTesting(): void {
|
|
38
|
+
globalConcurrencyLimit = Math.max(1, getMaxConcurrent());
|
|
39
|
+
globalConcurrencyRunning = 0;
|
|
40
|
+
globalConcurrencyWaiters.length = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function mapConcurrent<T, R>(
|
|
44
|
+
items: T[],
|
|
45
|
+
concurrency: number,
|
|
46
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
47
|
+
signal?: AbortSignal,
|
|
48
|
+
): Promise<R[]> {
|
|
49
|
+
if (items.length === 0) return [];
|
|
50
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
51
|
+
const results: R[] = new Array(items.length);
|
|
52
|
+
let next = 0;
|
|
53
|
+
const worker = async () => {
|
|
54
|
+
while (true) {
|
|
55
|
+
const i = next++;
|
|
56
|
+
if (i >= items.length) return;
|
|
57
|
+
// Deliberately do NOT check signal?.aborted here — the caller
|
|
58
|
+
// (runResolvedTask) handles abort at entry and returns a proper
|
|
59
|
+
// TaskResult. Early-returning here would leave results[i] as
|
|
60
|
+
// undefined, causing a crash in the sync result-dereference path.
|
|
61
|
+
results[i] = await fn(items[i]!, i);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Extract a model key string for concurrency grouping. Falls back to "_no_model" for actions without a model. */
|
|
69
|
+
export function getModelKey(model: Model<Api> | undefined): string {
|
|
70
|
+
// provider/id — e.g. "openrouter/deepseek/deepseek-v4-pro"
|
|
71
|
+
return model ? `${model.provider}/${model.id}` : "_no_model";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Like mapConcurrent but with per-model concurrency limits.
|
|
76
|
+
* Groups items by model key, runs each group with its own limit.
|
|
77
|
+
* All groups run in parallel (Promise.all across groups).
|
|
78
|
+
*
|
|
79
|
+
* The total number of concurrently running tasks is also capped by the
|
|
80
|
+
* configured `maxConcurrent` value, shared across all `delegate` invocations.
|
|
81
|
+
*/
|
|
82
|
+
export async function mapConcurrentByModel<T, R>(
|
|
83
|
+
items: T[],
|
|
84
|
+
getModelKey: (item: T, index: number) => string,
|
|
85
|
+
getConcurrency: (modelKey: string) => number,
|
|
86
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
87
|
+
signal?: AbortSignal,
|
|
88
|
+
): Promise<R[]> {
|
|
89
|
+
if (items.length === 0) return [];
|
|
90
|
+
const results: R[] = new Array(items.length);
|
|
91
|
+
|
|
92
|
+
// Group items by model key, preserving original indices
|
|
93
|
+
const groups = new Map<string, { indices: number[]; limit: number }>();
|
|
94
|
+
for (let i = 0; i < items.length; i++) {
|
|
95
|
+
const key = getModelKey(items[i]!, i);
|
|
96
|
+
let group = groups.get(key);
|
|
97
|
+
if (!group) {
|
|
98
|
+
group = { indices: [], limit: getConcurrency(key) };
|
|
99
|
+
groups.set(key, group);
|
|
100
|
+
}
|
|
101
|
+
group.indices.push(i);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Run all groups in parallel, each with its own concurrency limit + global cap
|
|
105
|
+
await Promise.all(
|
|
106
|
+
[...groups.entries()].map(([, group]) => {
|
|
107
|
+
const groupItems = group.indices.map((i) => items[i]!);
|
|
108
|
+
return mapConcurrent(
|
|
109
|
+
groupItems,
|
|
110
|
+
group.limit,
|
|
111
|
+
async (_item, localIdx) => {
|
|
112
|
+
await acquireGlobal();
|
|
113
|
+
try {
|
|
114
|
+
const globalIdx = group.indices[localIdx]!;
|
|
115
|
+
results[globalIdx] = await fn(_item, globalIdx);
|
|
116
|
+
return results[globalIdx];
|
|
117
|
+
} finally {
|
|
118
|
+
releaseGlobal();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
signal,
|
|
122
|
+
);
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
return results;
|
|
126
|
+
}
|