@bermudi/pi-delegate 0.1.1 → 0.1.3
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 +3 -2
- package/agents.ts +163 -17
- package/concurrency.ts +90 -10
- package/config.ts +61 -0
- package/delegate.ts +14 -0
- package/dispatch.ts +126 -21
- package/extension.ts +309 -62
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/leaf.ts +48 -0
- package/lifecycle.ts +129 -35
- package/manual.ts +38 -10
- package/package.json +4 -1
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/render-branches.ts +31 -13
- package/render-result.ts +12 -0
- package/runner.ts +237 -42
- package/schema.ts +204 -47
- package/status.ts +68 -2
- package/task-resolution.ts +101 -65
- package/telemetry.ts +738 -0
- package/tickets.ts +196 -61
- package/tools.ts +16 -15
- package/types.ts +71 -13
- package/usage.ts +19 -0
package/README.md
CHANGED
|
@@ -126,8 +126,9 @@ over an installed extension.
|
|
|
126
126
|
- **Resumed subagent** — A subagent rehydrated from a previous session `.jsonl`
|
|
127
127
|
via `resumeFrom`. It can also be pooled by providing a `sessionId`.
|
|
128
128
|
- **Async ticket** — A background execution handle returned when top-level
|
|
129
|
-
`async: true` is used. Poll or cancel tickets with top-level
|
|
130
|
-
|
|
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"`.
|
|
131
132
|
- **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
|
|
132
133
|
prompt. Skills are text instructions only; they do not unlock additional
|
|
133
134
|
tools.
|
package/agents.ts
CHANGED
|
@@ -75,11 +75,15 @@ export function parseFrontmatter(
|
|
|
75
75
|
|
|
76
76
|
// A bare `*` is a YAML alias indicator and is invalid as a scalar, so
|
|
77
77
|
// `tools: *` (the full-agent shorthand) would throw. Quote any value that is
|
|
78
|
-
// exactly
|
|
79
|
-
//
|
|
80
|
-
//
|
|
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.)
|
|
81
82
|
const sanitized = sanitizeYamlScalars(
|
|
82
|
-
yamlString.replace(
|
|
83
|
+
yamlString.replace(
|
|
84
|
+
/^([ \t]*[\w-]+:[ \t]*)\*([ \t]*(?:#[^\r\n]*)?)(?=\r?$)/gm,
|
|
85
|
+
'$1"*"$2',
|
|
86
|
+
),
|
|
83
87
|
);
|
|
84
88
|
|
|
85
89
|
try {
|
|
@@ -140,11 +144,13 @@ const CLAUDE_TOOL_ALIASES: Record<string, string> = {
|
|
|
140
144
|
|
|
141
145
|
/** Parse a `tools:` frontmatter value into a resolved tool list.
|
|
142
146
|
* Omitted or blank → inherit the full agent set (`*`), matching CC/OpenCode/
|
|
143
|
-
* Devin convention.
|
|
144
|
-
*
|
|
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. */
|
|
145
150
|
function resolveFrontmatterTools(
|
|
146
151
|
raw: string | undefined,
|
|
147
152
|
aliasMap?: Record<string, string>,
|
|
153
|
+
filePath?: string,
|
|
148
154
|
): string[] {
|
|
149
155
|
if (!raw) return DEFAULT_TOOLS; // omitted/blank → inherit *
|
|
150
156
|
const names = raw
|
|
@@ -154,11 +160,25 @@ function resolveFrontmatterTools(
|
|
|
154
160
|
if (!names.length) return DEFAULT_TOOLS;
|
|
155
161
|
const mapped = aliasMap
|
|
156
162
|
? names
|
|
157
|
-
.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
|
+
})
|
|
158
169
|
.filter((n): n is string => n !== null)
|
|
159
170
|
: names;
|
|
160
|
-
// Empty after aliasing (e.g. a Claude agent listing only WebSearch)
|
|
161
|
-
|
|
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);
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
/** Parse and alias a comma-separated Claude tool list into delegate tool names,
|
|
@@ -201,15 +221,19 @@ export function loadAgentFile(filePath: string): AgentConfig | null {
|
|
|
201
221
|
};
|
|
202
222
|
}
|
|
203
223
|
|
|
204
|
-
/** Variant for `.claude/agents/*.md` files.
|
|
224
|
+
/** Variant for `.claude/agents/*.md` files. Claude-specific adaptations:
|
|
205
225
|
* - Maps capitalized tool names (Read/Glob/…) to delegate tools, dropping
|
|
206
226
|
* unmappable ones (WebSearch, TodoWrite, …). Omitted `tools` inherits `*`.
|
|
207
227
|
* - Honors `disallowedTools` as a denylist layered on top of the resolved
|
|
208
228
|
* set (Claude semantics: denylist applies whether or not an allowlist is
|
|
209
229
|
* set). Since delegate has no runtime denylist, we bake it into `tools` at
|
|
210
|
-
* import time.
|
|
211
|
-
* `
|
|
212
|
-
*
|
|
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`.
|
|
213
237
|
* - `model: inherit` (Claude's default) is mapped to "omit" so the agent
|
|
214
238
|
* inherits the parent model; passing it through verbatim would crash
|
|
215
239
|
* resolveModel() with "model 'inherit' is not available". */
|
|
@@ -223,11 +247,32 @@ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
|
|
|
223
247
|
const { data, body } = parseFrontmatter(content, filePath);
|
|
224
248
|
if (!data.name || !data.description) return null;
|
|
225
249
|
|
|
226
|
-
|
|
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
|
+
);
|
|
227
259
|
// disallowedTools is a denylist applied after the allowlist resolves.
|
|
228
|
-
//
|
|
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.
|
|
229
265
|
const denied = new Set(mapClaudeToolNames(data.disallowedTools));
|
|
230
|
-
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
|
+
}
|
|
231
276
|
|
|
232
277
|
return {
|
|
233
278
|
name: data.name,
|
|
@@ -324,6 +369,95 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
324
369
|
export const DEFAULT_SUBAGENT_SYSTEM_PROMPT =
|
|
325
370
|
"You are a helpful coding assistant.";
|
|
326
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
|
+
|
|
327
461
|
function firstNonBlank(
|
|
328
462
|
...values: Array<string | undefined>
|
|
329
463
|
): string | undefined {
|
|
@@ -338,6 +472,7 @@ export function buildSubagentSystemPrompt(options: {
|
|
|
338
472
|
agentSystemPrompt?: string;
|
|
339
473
|
parentSystemPrompt?: string;
|
|
340
474
|
pooledSystemPrompt?: string;
|
|
475
|
+
tools?: string[];
|
|
341
476
|
}): string {
|
|
342
477
|
// Pooled agents already have a frozen prompt baked into their session state.
|
|
343
478
|
// Return it unchanged so repeated sessionId calls do not re-resolve.
|
|
@@ -347,11 +482,22 @@ export function buildSubagentSystemPrompt(options: {
|
|
|
347
482
|
// from this custom prompt + its own resource-loader discovery (skills,
|
|
348
483
|
// AGENTS.md, active-tool snippets). We previously appended skills/AGENTS.md
|
|
349
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
|
+
|
|
350
496
|
const base =
|
|
351
497
|
firstNonBlank(
|
|
352
498
|
options.taskSystemPrompt,
|
|
353
499
|
options.agentSystemPrompt,
|
|
354
|
-
|
|
500
|
+
parentSystemPrompt,
|
|
355
501
|
) ?? DEFAULT_SUBAGENT_SYSTEM_PROMPT;
|
|
356
502
|
|
|
357
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
|
|
|
@@ -61,7 +115,17 @@ async function mapConcurrent<T, R>(
|
|
|
61
115
|
results[i] = await fn(items[i]!, i);
|
|
62
116
|
}
|
|
63
117
|
};
|
|
64
|
-
|
|
118
|
+
// Promise.all would reject as soon as one task throws while sibling workers
|
|
119
|
+
// are still unwinding. Wait for every worker first so callers can safely use
|
|
120
|
+
// this promise as the batch-settled barrier (notably shutdown telemetry).
|
|
121
|
+
const outcomes = await Promise.allSettled(
|
|
122
|
+
Array.from({ length: limit }, () => worker()),
|
|
123
|
+
);
|
|
124
|
+
const rejection = outcomes.find(
|
|
125
|
+
(outcome): outcome is PromiseRejectedResult =>
|
|
126
|
+
outcome.status === "rejected",
|
|
127
|
+
);
|
|
128
|
+
if (rejection) throw rejection.reason;
|
|
65
129
|
return results;
|
|
66
130
|
}
|
|
67
131
|
|
|
@@ -101,17 +165,28 @@ export async function mapConcurrentByModel<T, R>(
|
|
|
101
165
|
group.indices.push(i);
|
|
102
166
|
}
|
|
103
167
|
|
|
104
|
-
// Run all groups in parallel, each with its own concurrency limit + global cap
|
|
105
|
-
|
|
168
|
+
// Run all groups in parallel, each with its own concurrency limit + global cap.
|
|
169
|
+
// As above, wait for every group before surfacing an unexpected rejection so
|
|
170
|
+
// a late sibling cannot mutate a ticket after its completion barrier resolves.
|
|
171
|
+
const outcomes = await Promise.allSettled(
|
|
106
172
|
[...groups.entries()].map(([, group]) => {
|
|
107
173
|
const groupItems = group.indices.map((i) => items[i]!);
|
|
108
174
|
return mapConcurrent(
|
|
109
175
|
groupItems,
|
|
110
176
|
group.limit,
|
|
111
177
|
async (_item, localIdx) => {
|
|
112
|
-
|
|
178
|
+
const globalIdx = group.indices[localIdx]!;
|
|
179
|
+
const acquired = await acquireGlobal(signal);
|
|
180
|
+
if (!acquired) {
|
|
181
|
+
// Aborted while queued for a global slot: we hold no slot, so we
|
|
182
|
+
// must NOT release one. Still invoke fn — runResolvedTask observes
|
|
183
|
+
// the aborted signal at entry and returns an "Aborted" TaskResult,
|
|
184
|
+
// keeping the results array dense for the sync dereference path
|
|
185
|
+
// (the same contract as mapConcurrent's worker loop).
|
|
186
|
+
results[globalIdx] = await fn(_item, globalIdx);
|
|
187
|
+
return results[globalIdx];
|
|
188
|
+
}
|
|
113
189
|
try {
|
|
114
|
-
const globalIdx = group.indices[localIdx]!;
|
|
115
190
|
results[globalIdx] = await fn(_item, globalIdx);
|
|
116
191
|
return results[globalIdx];
|
|
117
192
|
} finally {
|
|
@@ -122,5 +197,10 @@ export async function mapConcurrentByModel<T, R>(
|
|
|
122
197
|
);
|
|
123
198
|
}),
|
|
124
199
|
);
|
|
200
|
+
const rejection = outcomes.find(
|
|
201
|
+
(outcome): outcome is PromiseRejectedResult =>
|
|
202
|
+
outcome.status === "rejected",
|
|
203
|
+
);
|
|
204
|
+
if (rejection) throw rejection.reason;
|
|
125
205
|
return results;
|
|
126
206
|
}
|
package/config.ts
CHANGED
|
@@ -8,6 +8,52 @@ import {
|
|
|
8
8
|
OUTPUT_SPILL_THRESHOLD_CHARS,
|
|
9
9
|
} from "./constants.ts";
|
|
10
10
|
|
|
11
|
+
export interface TelemetryConfig {
|
|
12
|
+
/** Whether to record delegate calls to the local SQLite store. Default true. */
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
/** Path to the SQLite database. Defaults to `~/.pi/agent/delegate-usage.db`. */
|
|
15
|
+
dbPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate the user-editable telemetry block at its boundary. An explicitly
|
|
28
|
+
* malformed block disables telemetry rather than silently turning it on with
|
|
29
|
+
* the default database path. Missing telemetry is different: it means the
|
|
30
|
+
* user did not configure the feature, so the default remains enabled.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeTelemetryConfig(raw: unknown): TelemetryConfig {
|
|
33
|
+
if (raw === undefined) return { ...DEFAULT_TELEMETRY_CONFIG };
|
|
34
|
+
if (!isRecord(raw)) return { enabled: false };
|
|
35
|
+
|
|
36
|
+
const hasEnabled = Object.prototype.hasOwnProperty.call(raw, "enabled");
|
|
37
|
+
const hasDbPath = Object.prototype.hasOwnProperty.call(raw, "dbPath");
|
|
38
|
+
const enabled = raw.enabled;
|
|
39
|
+
const dbPath = raw.dbPath;
|
|
40
|
+
|
|
41
|
+
if (hasEnabled && typeof enabled !== "boolean") {
|
|
42
|
+
return { enabled: false };
|
|
43
|
+
}
|
|
44
|
+
if (hasDbPath && (typeof dbPath !== "string" || dbPath.trim().length === 0)) {
|
|
45
|
+
return { enabled: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const normalizedEnabled =
|
|
49
|
+
typeof enabled === "boolean" ? enabled : DEFAULT_TELEMETRY_CONFIG.enabled;
|
|
50
|
+
const normalizedDbPath = typeof dbPath === "string" ? dbPath : undefined;
|
|
51
|
+
return {
|
|
52
|
+
enabled: normalizedEnabled,
|
|
53
|
+
...(normalizedDbPath === undefined ? {} : { dbPath: normalizedDbPath }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
11
57
|
export interface DelegateConfig {
|
|
12
58
|
agent: {
|
|
13
59
|
/** Global default model for all agent types. */
|
|
@@ -41,6 +87,8 @@ export interface DelegateConfig {
|
|
|
41
87
|
providerExtensions?: {
|
|
42
88
|
[provider: string]: readonly string[];
|
|
43
89
|
};
|
|
90
|
+
/** Local SQLite telemetry for usage/health analytics. See `telemetry.ts`. */
|
|
91
|
+
telemetry?: TelemetryConfig;
|
|
44
92
|
/** LLM-facing output bounding: over-threshold final output is spilled to a
|
|
45
93
|
* temp file with a tail kept in-context. See `spill.ts`. */
|
|
46
94
|
output?: {
|
|
@@ -108,6 +156,9 @@ const DEFAULT_DELEGATE_CONFIG: DelegateConfig = {
|
|
|
108
156
|
wholeTaskBaseDelayMs: 1_000,
|
|
109
157
|
},
|
|
110
158
|
providerExtensions: DEFAULT_PROVIDER_EXTENSIONS,
|
|
159
|
+
telemetry: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
},
|
|
111
162
|
output: {
|
|
112
163
|
spillThresholdChars: OUTPUT_SPILL_THRESHOLD_CHARS,
|
|
113
164
|
spillTailChars: OUTPUT_SPILL_TAIL_CHARS,
|
|
@@ -119,6 +170,7 @@ let __delegateConfig: DelegateConfig = {
|
|
|
119
170
|
...DEFAULT_DELEGATE_CONFIG,
|
|
120
171
|
agent: { ...DEFAULT_DELEGATE_CONFIG.agent },
|
|
121
172
|
concurrency: { ...DEFAULT_DELEGATE_CONFIG.concurrency },
|
|
173
|
+
telemetry: { ...DEFAULT_DELEGATE_CONFIG.telemetry },
|
|
122
174
|
};
|
|
123
175
|
let stallTimeoutOverrideForTesting: number | undefined;
|
|
124
176
|
|
|
@@ -140,6 +192,7 @@ export function loadDelegateConfig(): DelegateConfig {
|
|
|
140
192
|
},
|
|
141
193
|
retry: { ...DEFAULT_DELEGATE_CONFIG.retry, ...(parsed.retry ?? {}) },
|
|
142
194
|
providerExtensions: resolveProviderExtensions(parsed.providerExtensions),
|
|
195
|
+
telemetry: normalizeTelemetryConfig(parsed.telemetry),
|
|
143
196
|
output: { ...DEFAULT_DELEGATE_CONFIG.output, ...(parsed.output ?? {}) },
|
|
144
197
|
} as DelegateConfig;
|
|
145
198
|
} catch {
|
|
@@ -203,6 +256,7 @@ export function _setDelegateConfigForTesting(
|
|
|
203
256
|
...(config.retry ?? {}),
|
|
204
257
|
},
|
|
205
258
|
providerExtensions: resolveProviderExtensions(config.providerExtensions),
|
|
259
|
+
telemetry: normalizeTelemetryConfig(config.telemetry),
|
|
206
260
|
output: {
|
|
207
261
|
...DEFAULT_DELEGATE_CONFIG.output,
|
|
208
262
|
...(config.output ?? {}),
|
|
@@ -356,3 +410,10 @@ export function resolveModelSpec(options: {
|
|
|
356
410
|
(v): v is string => typeof v === "string" && v.length > 0,
|
|
357
411
|
);
|
|
358
412
|
}
|
|
413
|
+
|
|
414
|
+
/** Get the configured telemetry settings. */
|
|
415
|
+
export function getTelemetryConfig(
|
|
416
|
+
config: DelegateConfig = __delegateConfig,
|
|
417
|
+
): TelemetryConfig {
|
|
418
|
+
return normalizeTelemetryConfig(config.telemetry);
|
|
419
|
+
}
|
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,
|
|
@@ -58,6 +59,7 @@ export {
|
|
|
58
59
|
ticketRegistry,
|
|
59
60
|
sweepTickets,
|
|
60
61
|
cancelTicketForShutdown,
|
|
62
|
+
requestTicketCancel,
|
|
61
63
|
isSessionBusy,
|
|
62
64
|
handlePoll,
|
|
63
65
|
handleCancel,
|
|
@@ -67,6 +69,13 @@ export {
|
|
|
67
69
|
resolveFinalTicketStatus,
|
|
68
70
|
formatCompletedTicket,
|
|
69
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";
|
|
70
79
|
export { runAgentSession } from "./runner.ts";
|
|
71
80
|
export {
|
|
72
81
|
activeTicketSummary,
|
|
@@ -76,11 +85,14 @@ export {
|
|
|
76
85
|
syncDelegateStatus,
|
|
77
86
|
notifyActiveTicketsOnSettled,
|
|
78
87
|
guardSessionReplacement,
|
|
88
|
+
guardTreeNavigation,
|
|
89
|
+
notifyCrossLeafDelivery,
|
|
79
90
|
} from "./status.ts";
|
|
80
91
|
export type { ActiveTicketSummary } from "./status.ts";
|
|
81
92
|
export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
|
|
82
93
|
export type { HostDeps, HostDepsOptions } from "./host.ts";
|
|
83
94
|
export {
|
|
95
|
+
aggregateTaskResults,
|
|
84
96
|
emptyUsage,
|
|
85
97
|
snapshotSessionUsage,
|
|
86
98
|
usageDelta,
|
|
@@ -99,6 +111,8 @@ export {
|
|
|
99
111
|
indent,
|
|
100
112
|
formatFailedTask,
|
|
101
113
|
formatCompletedTask,
|
|
114
|
+
findTouchedOverlaps,
|
|
115
|
+
formatTouchedOverlapWarning,
|
|
102
116
|
} from "./format.ts";
|
|
103
117
|
export {
|
|
104
118
|
parseFrontmatter,
|