@bermudi/pi-delegate 0.1.0 → 0.1.2
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 +55 -10
- package/agents.ts +176 -19
- package/concurrency.ts +70 -7
- package/constants.ts +3 -0
- package/delegate.ts +26 -1
- package/dispatch.ts +65 -9
- package/extension.ts +86 -6
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/leaf.ts +48 -0
- package/lifecycle.ts +292 -95
- package/manual.ts +45 -11
- package/model.ts +3 -4
- package/package.json +25 -20
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/pool.ts +169 -51
- package/render-branches.ts +52 -16
- package/render-result.ts +12 -0
- package/runner.ts +255 -51
- package/schema.ts +252 -63
- package/status.ts +269 -0
- package/task-resolution.ts +196 -77
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +62 -13
package/README.md
CHANGED
|
@@ -11,13 +11,32 @@ as a standalone repo (full history preserved).
|
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Install a reviewed Git commit or release tag as a Pi package:
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
|
|
17
|
+
# Replace this with a reviewed commit or release tag; do not use a moving branch.
|
|
18
|
+
pi install git:github.com/bermudi/pi-delegate@<reviewed-commit-or-tag>
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
Remove any old `delegate.ts` symlink from `~/.pi/agent/extensions/` before
|
|
22
|
+
starting Pi. Pi loads `delegate.ts` from the isolated Git package checkout; do
|
|
23
|
+
not point a running Pi at this repository or at `.build/delegate.bundle.ts`.
|
|
24
|
+
Start a fresh Pi process after updating the installed ref.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Use the built-in `default` profile to run a subagent with the live parent's
|
|
29
|
+
model, thinking level, delegatable native tools, and base system prompt:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
delegate({
|
|
33
|
+
tasks: [{ agent: "default", prompt: "Investigate the auth module" }],
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Parent extension/MCP tools are not copied, and project instructions are rebuilt
|
|
38
|
+
for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
|
|
39
|
+
normal inline defaults instead.
|
|
21
40
|
|
|
22
41
|
### Token accounting
|
|
23
42
|
|
|
@@ -30,6 +49,25 @@ which has no usage slot. The per-call aggregate (`Nk tokens`) is still shown in
|
|
|
30
49
|
the delegate header for both modes. Use sync delegation when totals must roll
|
|
31
50
|
into the session.
|
|
32
51
|
|
|
52
|
+
### Background-work visibility
|
|
53
|
+
|
|
54
|
+
Async tickets keep running after the parent's turn settles, and pi renders an
|
|
55
|
+
idle session — so delegate adds three signals:
|
|
56
|
+
|
|
57
|
+
- **Footer status** — while any ticket is active, the footer shows
|
|
58
|
+
`⏳ 2 subagents · t5042v19`, updated live as subagents start and finish.
|
|
59
|
+
- **Settle warning** — the first time a turn settles with a ticket still
|
|
60
|
+
active, a warning notification names the ticket and reminds you that
|
|
61
|
+
quitting aborts it. Once per ticket; the footer carries it from there.
|
|
62
|
+
- **Switch/fork guard** — `/new`, `/resume`, and forking ask for confirmation
|
|
63
|
+
before killing live subagents (pi lets extensions cancel those paths).
|
|
64
|
+
|
|
65
|
+
Quitting (Ctrl+C×2 / Ctrl+D / `/quit`) and `/reload` **cannot be intercepted**
|
|
66
|
+
by an extension — pi's `session_shutdown` is advisory. The footer status is
|
|
67
|
+
the mitigation there; on quit, delegate also prints a trace line to the
|
|
68
|
+
terminal naming the aborted tickets and agents, and on `/reload` it shows a
|
|
69
|
+
warning notification.
|
|
70
|
+
|
|
33
71
|
### Stall detection and cancellation
|
|
34
72
|
|
|
35
73
|
`stallTimeoutMs` is an inactivity watchdog, not a hard execution deadline. When
|
|
@@ -46,13 +84,15 @@ task result. Set `stallTimeoutMs` to `0` to disable the watchdog.
|
|
|
46
84
|
|
|
47
85
|
```bash
|
|
48
86
|
bun install
|
|
49
|
-
bun run build # regenerate delegate.bundle.ts
|
|
50
87
|
bun run typecheck
|
|
51
88
|
bun test
|
|
89
|
+
bun run build # optional disposable bundle smoke test
|
|
52
90
|
```
|
|
53
91
|
|
|
54
|
-
The
|
|
55
|
-
|
|
92
|
+
The package entry point is `delegate.ts`; `extension.ts` holds the tool
|
|
93
|
+
implementation. `.build/delegate.bundle.ts` is generated by `bun run build` and
|
|
94
|
+
is only a verification artifact. Never symlink it into a running Pi or build
|
|
95
|
+
over an installed extension.
|
|
56
96
|
|
|
57
97
|
## Glossary
|
|
58
98
|
|
|
@@ -61,6 +101,9 @@ The unbundled entry is `delegate.ts`; `extension.ts` holds the tool implementati
|
|
|
61
101
|
`systemPrompt`, `thinking`, `cwd`, `context`, `sessionId`, or `resumeFrom`.
|
|
62
102
|
`model` is also accepted but should be rare — subagents inherit the parent
|
|
63
103
|
model by default.
|
|
104
|
+
- **Default subagent** — The reserved built-in `agent: "default"` profile. It
|
|
105
|
+
mirrors the live parent's model, thinking level, delegatable native tools, and
|
|
106
|
+
base system prompt while preserving delegate's extension/context isolation.
|
|
64
107
|
- **Custom agent** — A subagent profile defined by the parent, either inline in
|
|
65
108
|
a delegate task (`systemPrompt`, `tools`, and `thinking`) or persisted as a
|
|
66
109
|
Markdown file. The subagent inherits the parent model by default; `model` is a
|
|
@@ -83,10 +126,12 @@ The unbundled entry is `delegate.ts`; `extension.ts` holds the tool implementati
|
|
|
83
126
|
- **Resumed subagent** — A subagent rehydrated from a previous session `.jsonl`
|
|
84
127
|
via `resumeFrom`. It can also be pooled by providing a `sessionId`.
|
|
85
128
|
- **Async ticket** — A background execution handle returned when top-level
|
|
86
|
-
`async: true` is used. Poll or cancel tickets with top-level
|
|
87
|
-
|
|
129
|
+
`async: true` is used. Poll, wait, or cancel tickets with top-level
|
|
130
|
+
`ticketAction: "poll"`, `ticketAction: "wait"` (blocks until the ticket settles;
|
|
131
|
+
optional `timeoutMs`), or `ticketAction: "cancel"`.
|
|
88
132
|
- **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
|
|
89
133
|
prompt. Skills are text instructions only; they do not unlock additional
|
|
90
134
|
tools.
|
|
91
|
-
- **AGENTS.md context** — Project and
|
|
92
|
-
appended to subagent system prompts
|
|
135
|
+
- **AGENTS.md context** — Project and ancestor guidance files are automatically
|
|
136
|
+
appended to subagent system prompts. User-global AGENTS.md files are excluded;
|
|
137
|
+
they describe the parent harness, not the delegated task.
|
package/agents.ts
CHANGED
|
@@ -6,7 +6,11 @@ import type {
|
|
|
6
6
|
ThinkingLevel,
|
|
7
7
|
} from "@earendil-works/pi-agent-core";
|
|
8
8
|
import { parseFrontmatter as parsePiFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_AGENT_NAME,
|
|
11
|
+
DEFAULT_TOOLS,
|
|
12
|
+
VALID_THINKING,
|
|
13
|
+
} from "./constants.ts";
|
|
10
14
|
import { resolveToolGroups } from "./tools.ts";
|
|
11
15
|
import type { AgentConfig } from "./types.ts";
|
|
12
16
|
|
|
@@ -71,11 +75,15 @@ export function parseFrontmatter(
|
|
|
71
75
|
|
|
72
76
|
// A bare `*` is a YAML alias indicator and is invalid as a scalar, so
|
|
73
77
|
// `tools: *` (the full-agent shorthand) would throw. Quote any value that is
|
|
74
|
-
// exactly
|
|
75
|
-
//
|
|
76
|
-
//
|
|
78
|
+
// exactly `*`, including when it has a trailing YAML comment, so it parses
|
|
79
|
+
// as the string "*", which resolveFrontmatterTools then expands via
|
|
80
|
+
// TOOL_GROUPS. (A `*` mid-scalar, e.g. `use * here`, is a legal plain scalar
|
|
81
|
+
// and needs no quoting.)
|
|
77
82
|
const sanitized = sanitizeYamlScalars(
|
|
78
|
-
yamlString.replace(
|
|
83
|
+
yamlString.replace(
|
|
84
|
+
/^([ \t]*[\w-]+:[ \t]*)\*([ \t]*(?:#[^\r\n]*)?)(?=\r?$)/gm,
|
|
85
|
+
'$1"*"$2',
|
|
86
|
+
),
|
|
79
87
|
);
|
|
80
88
|
|
|
81
89
|
try {
|
|
@@ -136,11 +144,13 @@ const CLAUDE_TOOL_ALIASES: Record<string, string> = {
|
|
|
136
144
|
|
|
137
145
|
/** Parse a `tools:` frontmatter value into a resolved tool list.
|
|
138
146
|
* Omitted or blank → inherit the full agent set (`*`), matching CC/OpenCode/
|
|
139
|
-
* Devin convention.
|
|
140
|
-
*
|
|
147
|
+
* Devin convention. For Claude imports (`aliasMap` set), an explicit
|
|
148
|
+
* allowlist that maps to an empty set means the agent gets no tools, with a
|
|
149
|
+
* warning, instead of silently inheriting anything. */
|
|
141
150
|
function resolveFrontmatterTools(
|
|
142
151
|
raw: string | undefined,
|
|
143
152
|
aliasMap?: Record<string, string>,
|
|
153
|
+
filePath?: string,
|
|
144
154
|
): string[] {
|
|
145
155
|
if (!raw) return DEFAULT_TOOLS; // omitted/blank → inherit *
|
|
146
156
|
const names = raw
|
|
@@ -150,11 +160,25 @@ function resolveFrontmatterTools(
|
|
|
150
160
|
if (!names.length) return DEFAULT_TOOLS;
|
|
151
161
|
const mapped = aliasMap
|
|
152
162
|
? names
|
|
153
|
-
.map((n) =>
|
|
163
|
+
.map((n) => {
|
|
164
|
+
const lower = n.toLowerCase();
|
|
165
|
+
// Preserve delegate tool-group shorthands when importing Claude files.
|
|
166
|
+
if (lower === "*" || lower === "ro") return lower;
|
|
167
|
+
return aliasMap[lower] ?? null;
|
|
168
|
+
})
|
|
154
169
|
.filter((n): n is string => n !== null)
|
|
155
170
|
: names;
|
|
156
|
-
// Empty after aliasing (e.g. a Claude agent listing only WebSearch)
|
|
157
|
-
|
|
171
|
+
// Empty after aliasing (e.g. a Claude agent listing only WebSearch) must not
|
|
172
|
+
// silently inherit the full mutating set. An explicit allowlist that maps to
|
|
173
|
+
// nothing means the agent gets no tools.
|
|
174
|
+
if (!mapped.length) {
|
|
175
|
+
const where = filePath ? ` (${filePath})` : "";
|
|
176
|
+
console.warn(
|
|
177
|
+
`[delegate] Claude agent allowlist contained no mappable tools${where}; agent will have no tools.`,
|
|
178
|
+
);
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
return resolveToolGroups(mapped);
|
|
158
182
|
}
|
|
159
183
|
|
|
160
184
|
/** Parse and alias a comma-separated Claude tool list into delegate tool names,
|
|
@@ -197,15 +221,19 @@ export function loadAgentFile(filePath: string): AgentConfig | null {
|
|
|
197
221
|
};
|
|
198
222
|
}
|
|
199
223
|
|
|
200
|
-
/** Variant for `.claude/agents/*.md` files.
|
|
224
|
+
/** Variant for `.claude/agents/*.md` files. Claude-specific adaptations:
|
|
201
225
|
* - Maps capitalized tool names (Read/Glob/…) to delegate tools, dropping
|
|
202
226
|
* unmappable ones (WebSearch, TodoWrite, …). Omitted `tools` inherits `*`.
|
|
203
227
|
* - Honors `disallowedTools` as a denylist layered on top of the resolved
|
|
204
228
|
* set (Claude semantics: denylist applies whether or not an allowlist is
|
|
205
229
|
* set). Since delegate has no runtime denylist, we bake it into `tools` at
|
|
206
|
-
* import time.
|
|
207
|
-
* `
|
|
208
|
-
*
|
|
230
|
+
* import time. `bash` is special: in the delegate tool set it subsumes
|
|
231
|
+
* `grep`/`find`/`ls` (a shell can run all of them), so an imported `Bash`
|
|
232
|
+
* resolves to `bash` alone. If the denylist then removes `Bash` from an
|
|
233
|
+
* inherited (omitted) allowlist, the dedicated read-only search tools it
|
|
234
|
+
* was subsuming are restored. If the user explicitly allowlisted `Bash`,
|
|
235
|
+
* removing it gives them no extra tools — they never asked for `grep`,
|
|
236
|
+
* `find`, or `ls`.
|
|
209
237
|
* - `model: inherit` (Claude's default) is mapped to "omit" so the agent
|
|
210
238
|
* inherits the parent model; passing it through verbatim would crash
|
|
211
239
|
* resolveModel() with "model 'inherit' is not available". */
|
|
@@ -219,11 +247,32 @@ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
|
|
|
219
247
|
const { data, body } = parseFrontmatter(content, filePath);
|
|
220
248
|
if (!data.name || !data.description) return null;
|
|
221
249
|
|
|
222
|
-
|
|
250
|
+
// Track whether the user wrote an explicit `tools:` allowlist. Omitted or
|
|
251
|
+
// blank means "inherit the full default set" (`*`), and only in that case
|
|
252
|
+
// does denying `bash` restore the search tools it was subsuming.
|
|
253
|
+
const explicitTools = data.tools != null && data.tools.trim() !== "";
|
|
254
|
+
let tools = resolveFrontmatterTools(
|
|
255
|
+
data.tools,
|
|
256
|
+
CLAUDE_TOOL_ALIASES,
|
|
257
|
+
filePath,
|
|
258
|
+
);
|
|
223
259
|
// disallowedTools is a denylist applied after the allowlist resolves.
|
|
224
|
-
//
|
|
260
|
+
// `bash` subsumes `grep`/`find`/`ls`: if the denylist removes `bash` from an
|
|
261
|
+
// inherited (omitted) allowlist, restore the dedicated read-only search
|
|
262
|
+
// tools it was covering, but only the ones not already present and not also
|
|
263
|
+
// explicitly denied. If `bash` came from an explicit allowlist, do not add
|
|
264
|
+
// search tools the user never requested.
|
|
225
265
|
const denied = new Set(mapClaudeToolNames(data.disallowedTools));
|
|
226
|
-
if (denied.size)
|
|
266
|
+
if (denied.size) {
|
|
267
|
+
const hadBash = tools.includes("bash");
|
|
268
|
+
tools = tools.filter((t) => !denied.has(t));
|
|
269
|
+
if (hadBash && !tools.includes("bash") && !explicitTools) {
|
|
270
|
+
const restored = ["grep", "find", "ls"].filter(
|
|
271
|
+
(t) => !tools.includes(t) && !denied.has(t),
|
|
272
|
+
);
|
|
273
|
+
if (restored.length) tools = tools.concat(restored);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
227
276
|
|
|
228
277
|
return {
|
|
229
278
|
name: data.name,
|
|
@@ -294,7 +343,14 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
294
343
|
}
|
|
295
344
|
for (const e of entries) {
|
|
296
345
|
if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
|
|
297
|
-
const
|
|
346
|
+
const filePath = path.join(dir, e.name);
|
|
347
|
+
const cfg = loader(filePath);
|
|
348
|
+
if (cfg?.name === DEFAULT_AGENT_NAME) {
|
|
349
|
+
console.warn(
|
|
350
|
+
`[delegate] ignoring agent profile '${DEFAULT_AGENT_NAME}' from ${filePath}: the name is reserved for the built-in parent-mirroring profile.`,
|
|
351
|
+
);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
298
354
|
if (cfg && !agents.has(cfg.name)) {
|
|
299
355
|
cfg.scope = scope;
|
|
300
356
|
agents.set(cfg.name, cfg);
|
|
@@ -313,6 +369,95 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
313
369
|
export const DEFAULT_SUBAGENT_SYSTEM_PROMPT =
|
|
314
370
|
"You are a helpful coding assistant.";
|
|
315
371
|
|
|
372
|
+
/** First line of Pi's default generated system prompt. Used to detect an
|
|
373
|
+
* inherited fully-assembled parent prompt that contains a stale tool
|
|
374
|
+
* inventory. */
|
|
375
|
+
const PI_DEFAULT_PROMPT_PREFIX =
|
|
376
|
+
"You are an expert coding assistant operating inside pi, a coding agent harness.";
|
|
377
|
+
|
|
378
|
+
const PI_DEFAULT_PROMPT_INTRO = `${PI_DEFAULT_PROMPT_PREFIX} You help users by reading files, executing commands, editing code, and writing new files.`;
|
|
379
|
+
|
|
380
|
+
function buildCapabilityProse(tools: string[]): string {
|
|
381
|
+
const set = new Set(tools);
|
|
382
|
+
const parts: string[] = [];
|
|
383
|
+
if (set.has("read")) parts.push("reading files");
|
|
384
|
+
if (set.has("grep") || set.has("find")) parts.push("searching code");
|
|
385
|
+
if (set.has("ls")) parts.push("listing directories");
|
|
386
|
+
if (set.has("bash")) parts.push("executing commands");
|
|
387
|
+
if (set.has("edit")) parts.push("editing code");
|
|
388
|
+
if (set.has("write")) parts.push("writing new files");
|
|
389
|
+
if (parts.length === 0) return "following the provided instructions";
|
|
390
|
+
if (parts.length === 1) return parts[0]!;
|
|
391
|
+
if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
|
|
392
|
+
return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]!}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildIntro(tools: string[]): string {
|
|
396
|
+
return `${PI_DEFAULT_PROMPT_PREFIX} You help users by ${buildCapabilityProse(tools)}.`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const PI_DEFAULT_AVAILABLE_TOOLS_MARKER = "\n\nAvailable tools:\n";
|
|
400
|
+
const PI_DEFAULT_PI_DOCS_MARKER = "\n\nPi documentation";
|
|
401
|
+
const PI_DEFAULT_CWD_PREFIX = "\nCurrent working directory: ";
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Strip the default tool inventory, guidelines, and parent working directory
|
|
405
|
+
* line from an inherited parent prompt before it becomes a child's custom
|
|
406
|
+
* system prompt.
|
|
407
|
+
*
|
|
408
|
+
* Pi's `buildSystemPrompt()` synthesizes an "Available tools" list, tool
|
|
409
|
+
* guidelines, and a "Current working directory" line when no custom `SYSTEM.md`
|
|
410
|
+
* is in use. That assembled prompt is what the parent's `getSystemPrompt()`
|
|
411
|
+
* returns. If we pass it through as the child's custom prompt, the child keeps
|
|
412
|
+
* the parent's tool advertisement (and cwd) even when its actual tool set is
|
|
413
|
+
* restricted (e.g. `tools: ["ro"]`) and it is running in a different
|
|
414
|
+
* directory. AgentSession will append the correct child cwd when it rebuilds
|
|
415
|
+
* the system prompt, so the inherited parent cwd line is removed here.
|
|
416
|
+
*
|
|
417
|
+
* The function detects the default prompt by its stable intro, then removes
|
|
418
|
+
* only the "Available tools" + "Guidelines" region and the trailing
|
|
419
|
+
* "Current working directory" line, preserving the rest of the parent base
|
|
420
|
+
* (and any append/project context/skills that the resource loader added).
|
|
421
|
+
*
|
|
422
|
+
* Custom parent `SYSTEM.md` prompts are left untouched: they do not start with
|
|
423
|
+
* the default intro, so no heuristic stripping occurs.
|
|
424
|
+
*/
|
|
425
|
+
function sanitizeParentToolInventory(
|
|
426
|
+
prompt: string | undefined,
|
|
427
|
+
tools: string[] = DEFAULT_TOOLS,
|
|
428
|
+
): string | undefined {
|
|
429
|
+
if (!prompt) return prompt;
|
|
430
|
+
|
|
431
|
+
// Only act on Pi's default generated prompt. Custom prompts may mention tools
|
|
432
|
+
// intentionally and should not be rewritten by a heuristic.
|
|
433
|
+
const trimmed = prompt.trimStart();
|
|
434
|
+
if (!trimmed.startsWith(PI_DEFAULT_PROMPT_INTRO)) return prompt;
|
|
435
|
+
|
|
436
|
+
const availableStart = prompt.indexOf(PI_DEFAULT_AVAILABLE_TOOLS_MARKER);
|
|
437
|
+
if (availableStart < 0) return prompt;
|
|
438
|
+
|
|
439
|
+
const piDocsStart = prompt.indexOf(
|
|
440
|
+
PI_DEFAULT_PI_DOCS_MARKER,
|
|
441
|
+
availableStart + PI_DEFAULT_AVAILABLE_TOOLS_MARKER.length,
|
|
442
|
+
);
|
|
443
|
+
if (piDocsStart < 0) return prompt;
|
|
444
|
+
|
|
445
|
+
const intro = buildIntro(tools);
|
|
446
|
+
const prefix = prompt
|
|
447
|
+
.slice(0, availableStart)
|
|
448
|
+
.replace(PI_DEFAULT_PROMPT_INTRO, intro);
|
|
449
|
+
let base = prefix + prompt.slice(piDocsStart);
|
|
450
|
+
|
|
451
|
+
// Remove the inherited parent cwd line; AgentSession adds the correct child
|
|
452
|
+
// cwd when it assembles the final system prompt.
|
|
453
|
+
const cwdIndex = base.lastIndexOf(PI_DEFAULT_CWD_PREFIX);
|
|
454
|
+
if (cwdIndex >= 0) {
|
|
455
|
+
return base.slice(0, cwdIndex).replace(/\n+$/, "");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return base;
|
|
459
|
+
}
|
|
460
|
+
|
|
316
461
|
function firstNonBlank(
|
|
317
462
|
...values: Array<string | undefined>
|
|
318
463
|
): string | undefined {
|
|
@@ -327,6 +472,7 @@ export function buildSubagentSystemPrompt(options: {
|
|
|
327
472
|
agentSystemPrompt?: string;
|
|
328
473
|
parentSystemPrompt?: string;
|
|
329
474
|
pooledSystemPrompt?: string;
|
|
475
|
+
tools?: string[];
|
|
330
476
|
}): string {
|
|
331
477
|
// Pooled agents already have a frozen prompt baked into their session state.
|
|
332
478
|
// Return it unchanged so repeated sessionId calls do not re-resolve.
|
|
@@ -336,11 +482,22 @@ export function buildSubagentSystemPrompt(options: {
|
|
|
336
482
|
// from this custom prompt + its own resource-loader discovery (skills,
|
|
337
483
|
// AGENTS.md, active-tool snippets). We previously appended skills/AGENTS.md
|
|
338
484
|
// here; that duplicated AgentSession's work.
|
|
485
|
+
// An inherited parent prompt may be Pi's fully-assembled default prompt,
|
|
486
|
+
// which carries the parent's "Available tools" list and tool guidelines. A
|
|
487
|
+
// restricted child (e.g. `tools: ["ro"]`) must not advertise the parent's
|
|
488
|
+
// mutating tools as callable. Sanitize the parent prompt only; task and agent
|
|
489
|
+
// prompts are intentionally authored and are left untouched.
|
|
490
|
+
const resolvedTools = resolveToolGroups(options.tools ?? DEFAULT_TOOLS);
|
|
491
|
+
const parentSystemPrompt = sanitizeParentToolInventory(
|
|
492
|
+
options.parentSystemPrompt,
|
|
493
|
+
resolvedTools,
|
|
494
|
+
);
|
|
495
|
+
|
|
339
496
|
const base =
|
|
340
497
|
firstNonBlank(
|
|
341
498
|
options.taskSystemPrompt,
|
|
342
499
|
options.agentSystemPrompt,
|
|
343
|
-
|
|
500
|
+
parentSystemPrompt,
|
|
344
501
|
) ?? DEFAULT_SUBAGENT_SYSTEM_PROMPT;
|
|
345
502
|
|
|
346
503
|
return base;
|
package/concurrency.ts
CHANGED
|
@@ -10,21 +10,75 @@ import { getMaxConcurrent } from "./config.ts";
|
|
|
10
10
|
|
|
11
11
|
let globalConcurrencyLimit = Math.max(1, getMaxConcurrent());
|
|
12
12
|
let globalConcurrencyRunning = 0;
|
|
13
|
-
const globalConcurrencyWaiters: Array<() => void> = [];
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
interface GlobalWaiter {
|
|
15
|
+
/** Resolve with `true` when a slot was acquired, `false` when the signal
|
|
16
|
+
* aborted while queued (the caller must not release a slot it never held). */
|
|
17
|
+
resolve: (acquired: boolean) => void;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
onAbort: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const globalConcurrencyWaiters: GlobalWaiter[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Acquire a global concurrency slot.
|
|
26
|
+
*
|
|
27
|
+
* Resolves `true` when a slot is held (caller must pair with `releaseGlobal`),
|
|
28
|
+
* or `false` when `signal` aborted before a slot could be acquired. Waiters are
|
|
29
|
+
* abort-aware: an abort removes the queued waiter immediately and resolves
|
|
30
|
+
* `false`, so a cancelled ticket is not stranded in "cancelling" until some
|
|
31
|
+
* unrelated task happens to release capacity. The caller still invokes its fn
|
|
32
|
+
* on `false` — the production fn (runResolvedTask) observes the aborted signal
|
|
33
|
+
* at entry and returns an "Aborted" TaskResult without consuming a slot.
|
|
34
|
+
*/
|
|
35
|
+
function acquireGlobal(signal?: AbortSignal): Promise<boolean> {
|
|
36
|
+
// Already aborted: never queue (a slot would be wasted on a task that can
|
|
37
|
+
// only report Aborted) and never increment — nothing to release later.
|
|
38
|
+
if (signal?.aborted) return Promise.resolve(false);
|
|
16
39
|
if (globalConcurrencyRunning < globalConcurrencyLimit) {
|
|
17
40
|
globalConcurrencyRunning++;
|
|
18
|
-
return Promise.resolve();
|
|
41
|
+
return Promise.resolve(true);
|
|
19
42
|
}
|
|
20
|
-
return new Promise<
|
|
43
|
+
return new Promise<boolean>((resolve) => {
|
|
44
|
+
const waiter: GlobalWaiter = {
|
|
45
|
+
resolve,
|
|
46
|
+
signal,
|
|
47
|
+
onAbort: () => {
|
|
48
|
+
// Remove ourselves from the queue without taking a slot. The caller's
|
|
49
|
+
// fn sees the aborted signal at entry and settles promptly.
|
|
50
|
+
const index = globalConcurrencyWaiters.indexOf(waiter);
|
|
51
|
+
if (index === -1) return; // already woken by releaseGlobal
|
|
52
|
+
globalConcurrencyWaiters.splice(index, 1);
|
|
53
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
54
|
+
resolve(false);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
if (signal) {
|
|
58
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
59
|
+
}
|
|
60
|
+
globalConcurrencyWaiters.push(waiter);
|
|
61
|
+
});
|
|
21
62
|
}
|
|
22
63
|
|
|
23
64
|
function releaseGlobal(): void {
|
|
24
65
|
globalConcurrencyRunning--;
|
|
66
|
+
// Never hand a slot to a waiter whose signal already aborted. The abort
|
|
67
|
+
// listener normally removes it synchronously during abort() dispatch; this
|
|
68
|
+
// drain is defensive for any ordering edge.
|
|
69
|
+
while (
|
|
70
|
+
globalConcurrencyWaiters.length > 0 &&
|
|
71
|
+
globalConcurrencyWaiters[0]!.signal?.aborted
|
|
72
|
+
) {
|
|
73
|
+
const w = globalConcurrencyWaiters.shift()!;
|
|
74
|
+
w.signal?.removeEventListener("abort", w.onAbort);
|
|
75
|
+
w.resolve(false);
|
|
76
|
+
}
|
|
25
77
|
if (globalConcurrencyWaiters.length > 0) {
|
|
26
78
|
globalConcurrencyRunning++;
|
|
27
|
-
globalConcurrencyWaiters.shift()
|
|
79
|
+
const w = globalConcurrencyWaiters.shift()!;
|
|
80
|
+
w.signal?.removeEventListener("abort", w.onAbort);
|
|
81
|
+
w.resolve(true);
|
|
28
82
|
}
|
|
29
83
|
}
|
|
30
84
|
|
|
@@ -109,9 +163,18 @@ export async function mapConcurrentByModel<T, R>(
|
|
|
109
163
|
groupItems,
|
|
110
164
|
group.limit,
|
|
111
165
|
async (_item, localIdx) => {
|
|
112
|
-
|
|
166
|
+
const globalIdx = group.indices[localIdx]!;
|
|
167
|
+
const acquired = await acquireGlobal(signal);
|
|
168
|
+
if (!acquired) {
|
|
169
|
+
// Aborted while queued for a global slot: we hold no slot, so we
|
|
170
|
+
// must NOT release one. Still invoke fn — runResolvedTask observes
|
|
171
|
+
// the aborted signal at entry and returns an "Aborted" TaskResult,
|
|
172
|
+
// keeping the results array dense for the sync dereference path
|
|
173
|
+
// (the same contract as mapConcurrent's worker loop).
|
|
174
|
+
results[globalIdx] = await fn(_item, globalIdx);
|
|
175
|
+
return results[globalIdx];
|
|
176
|
+
}
|
|
113
177
|
try {
|
|
114
|
-
const globalIdx = group.indices[localIdx]!;
|
|
115
178
|
results[globalIdx] = await fn(_item, globalIdx);
|
|
116
179
|
return results[globalIdx];
|
|
117
180
|
} finally {
|
package/constants.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Reserved built-in profile that mirrors the live parent configuration. */
|
|
2
|
+
export const DEFAULT_AGENT_NAME = "default";
|
|
3
|
+
|
|
1
4
|
/** Full-capability agent set. Inline-task default and the `*` shorthand.
|
|
2
5
|
* Bash subsumes search, so the dedicated grep/find/ls tools are excluded. */
|
|
3
6
|
export const DEFAULT_TOOLS = ["read", "write", "edit", "bash"];
|
package/delegate.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default } from "./extension.ts";
|
|
|
3
3
|
export type {
|
|
4
4
|
AgentConfig,
|
|
5
5
|
SessionAction,
|
|
6
|
+
TicketAction,
|
|
6
7
|
DelegateAction,
|
|
7
8
|
DelegateArguments,
|
|
8
9
|
TaskDef,
|
|
@@ -14,12 +15,14 @@ export type {
|
|
|
14
15
|
TaskResult,
|
|
15
16
|
TaskFailureKind,
|
|
16
17
|
ReuseIntent,
|
|
18
|
+
ParentAgentDefaults,
|
|
17
19
|
AgentRunConfig,
|
|
18
20
|
TaskRunEnv,
|
|
19
21
|
} from "./types.ts";
|
|
20
22
|
export type { DelegateConfig } from "./config.ts";
|
|
21
23
|
|
|
22
24
|
export {
|
|
25
|
+
DEFAULT_AGENT_NAME,
|
|
23
26
|
DEFAULT_TOOLS,
|
|
24
27
|
READONLY_TOOLS,
|
|
25
28
|
MAX_CONCURRENCY,
|
|
@@ -56,6 +59,7 @@ export {
|
|
|
56
59
|
ticketRegistry,
|
|
57
60
|
sweepTickets,
|
|
58
61
|
cancelTicketForShutdown,
|
|
62
|
+
requestTicketCancel,
|
|
59
63
|
isSessionBusy,
|
|
60
64
|
handlePoll,
|
|
61
65
|
handleCancel,
|
|
@@ -65,8 +69,27 @@ export {
|
|
|
65
69
|
resolveFinalTicketStatus,
|
|
66
70
|
formatCompletedTicket,
|
|
67
71
|
} from "./tickets.ts";
|
|
72
|
+
export type { TicketDelivery } from "./tickets.ts";
|
|
73
|
+
export {
|
|
74
|
+
recordTreeNavigation,
|
|
75
|
+
getCurrentLeafId,
|
|
76
|
+
resetLeafTracking,
|
|
77
|
+
isCrossLeafTicket,
|
|
78
|
+
} from "./leaf.ts";
|
|
68
79
|
export { runAgentSession } from "./runner.ts";
|
|
69
|
-
export {
|
|
80
|
+
export {
|
|
81
|
+
activeTicketSummary,
|
|
82
|
+
buildStatusText,
|
|
83
|
+
clearDelegateStatusContext,
|
|
84
|
+
describeActiveTickets,
|
|
85
|
+
syncDelegateStatus,
|
|
86
|
+
notifyActiveTicketsOnSettled,
|
|
87
|
+
guardSessionReplacement,
|
|
88
|
+
guardTreeNavigation,
|
|
89
|
+
notifyCrossLeafDelivery,
|
|
90
|
+
} from "./status.ts";
|
|
91
|
+
export type { ActiveTicketSummary } from "./status.ts";
|
|
92
|
+
export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
|
|
70
93
|
export type { HostDeps, HostDepsOptions } from "./host.ts";
|
|
71
94
|
export {
|
|
72
95
|
emptyUsage,
|
|
@@ -87,6 +110,8 @@ export {
|
|
|
87
110
|
indent,
|
|
88
111
|
formatFailedTask,
|
|
89
112
|
formatCompletedTask,
|
|
113
|
+
findTouchedOverlaps,
|
|
114
|
+
formatTouchedOverlapWarning,
|
|
90
115
|
} from "./format.ts";
|
|
91
116
|
export {
|
|
92
117
|
parseFrontmatter,
|