@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 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 `action: "poll"`
130
- or `action: "cancel"`.
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 `*` so it parses as the string "*", which resolveFrontmatterTools
79
- // then expands via TOOL_GROUPS. (A `*` mid-scalar, e.g. `use * here`, is a
80
- // legal plain scalar and needs no quoting.)
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(/^(\s*[\w-]+):\s*\*(?=\s*$)/gm, '$1: "*"'),
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. This is the only caller-owned knob — both inline tasks
144
- * and named agents now inherit-all when `tools:` is absent. */
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) => aliasMap[n.toLowerCase()] ?? null)
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) inherit.
161
- return mapped.length ? resolveToolGroups(mapped) : DEFAULT_TOOLS;
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. Two Claude-specific adaptations:
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. A reviewer with `disallowedTools: Write, Edit` and no
211
- * `tools` becomes `read, bash, grep, find, ls` it does NOT silently
212
- * inherit full tools.
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
- let tools = resolveFrontmatterTools(data.tools, CLAUDE_TOOL_ALIASES);
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
- // Empty-after-mapping denylist (e.g. only unmappable names) no-op.
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) tools = tools.filter((t) => !denied.has(t));
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
- options.parentSystemPrompt,
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
- function acquireGlobal(): Promise<void> {
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<void>((r) => globalConcurrencyWaiters.push(r));
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
- await Promise.all(Array.from({ length: limit }, () => worker()));
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
- await Promise.all(
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
- await acquireGlobal();
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,