@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 CHANGED
@@ -11,13 +11,32 @@ as a standalone repo (full history preserved).
11
11
 
12
12
  ## Install
13
13
 
14
- Symlink the **bundled** output into Pi's global extensions dir:
14
+ Install a reviewed Git commit or release tag as a Pi package:
15
15
 
16
16
  ```bash
17
- ln -s "$PWD/delegate.bundle.ts" ~/.pi/agent/extensions/delegate.ts
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
- Then `/reload` in Pi.
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 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.
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 `action: "poll"`
87
- 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"`.
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 global guidance files automatically
92
- appended to subagent system prompts, separate from named agent definitions.
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 { DEFAULT_TOOLS, VALID_THINKING } from "./constants.ts";
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 `*` 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.)
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(/^(\s*[\w-]+):\s*\*(?=\s*$)/gm, '$1: "*"'),
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. This is the only caller-owned knob — both inline tasks
140
- * 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. */
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) => 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
+ })
154
169
  .filter((n): n is string => n !== null)
155
170
  : names;
156
- // Empty after aliasing (e.g. a Claude agent listing only WebSearch) inherit.
157
- 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);
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. Two Claude-specific adaptations:
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. A reviewer with `disallowedTools: Write, Edit` and no
207
- * `tools` becomes `read, bash, grep, find, ls` it does NOT silently
208
- * 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`.
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
- 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
+ );
223
259
  // disallowedTools is a denylist applied after the allowlist resolves.
224
- // 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.
225
265
  const denied = new Set(mapClaudeToolNames(data.disallowedTools));
226
- 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
+ }
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 cfg = loader(path.join(dir, e.name));
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
- options.parentSystemPrompt,
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
- 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
 
@@ -109,9 +163,18 @@ export async function mapConcurrentByModel<T, R>(
109
163
  groupItems,
110
164
  group.limit,
111
165
  async (_item, localIdx) => {
112
- await acquireGlobal();
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 { getHostDeps } from "./host.ts";
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,