@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/manual.ts CHANGED
@@ -70,10 +70,36 @@ export function getSubagentManualMarkdown(
70
70
  "**Why you're seeing this:** no tasks were provided, so the tool returned help instead of dispatching. Nothing is broken. To dispatch subagents, put task fields inside `tasks: [{ ... }]`.",
71
71
  "",
72
72
  "```ts",
73
- 'delegate({ tasks: [{ prompt: "Investigate the auth module" }] })',
73
+ 'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
74
74
  "```",
75
75
  "",
76
- "Delegate subagents to execute tasks in parallel. Each subagent gets an independent context, system prompt, model, tools, and thinking level. Custom agents can be defined inline in a task or persisted as Markdown files.",
76
+ "Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation but uses the real filesystem at its task `cwd`; tasks sharing a directory can observe and overwrite one another's changes. Fresh prompts must therefore be self-contained, and dependent or shared-file work should run separately.",
77
+ "",
78
+ "The three handles have different lifetimes:",
79
+ "",
80
+ "- **ticket** — controls one async batch with `poll`, `wait`, or `cancel`.",
81
+ "- **sessionId** — a caller-chosen key for a live multi-turn worker, retained until close or parent shutdown.",
82
+ "- **resumeFrom** — an absolute `.jsonl` transcript path used to recover an interrupted worker.",
83
+ "",
84
+ "Each task entry may also carry an optional `id` — a caller-provided per-dispatch correlation key. Duplicate `id` values in the same call are rejected; when omitted, tasks are identified by array index, agent, and prompt.",
85
+ "",
86
+ "Subagents cannot call `delegate` recursively. Their tool activity runs at `cwd`, while Pi stores the runtime session transcript in its own session directory outside that `cwd`.",
87
+ "",
88
+ "## Touched Files (best-effort)",
89
+ "",
90
+ "The `touched:` list in each task result is a **best-effort lower bound**, not an authoritative record.",
91
+ "",
92
+ "- `write` and `edit` tool calls are captured reliably from the activity log.",
93
+ "- `bash` mutations are captured only when the task `cwd` is inside a git repo and git is available, via `git status` against the pre-run baseline.",
94
+ "- In a non-git directory, bash-mutated files are not reported.",
95
+ "- Git failures degrade to an empty diff.",
96
+ "- A path missing from `touched:` does **not** mean the file was unchanged. Delegate does not isolate file access or roll back writes.",
97
+ "",
98
+ "## Built-in Agent",
99
+ "",
100
+ "- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base system prompt.",
101
+ "",
102
+ "Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context is rebuilt safely for the task's `cwd`; per-task fields remain explicit overrides.",
77
103
  "",
78
104
  "## Available Custom Agents",
79
105
  "",
@@ -112,10 +138,10 @@ export function getSubagentManualMarkdown(
112
138
  'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
113
139
  "",
114
140
  "// Clean up when done",
115
- 'delegate({ tasks: [{ sessionId: "auth-research", action: "close" }] })',
141
+ 'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
116
142
  "```",
117
143
  "",
118
- 'Pooled agents remain live until `action: "close"` or parent Pi session shutdown.',
144
+ 'Pooled agents remain live until `sessionAction: "close"` or parent Pi session shutdown.',
119
145
  "",
120
146
  "## Resuming Previous Sessions",
121
147
  "",
@@ -152,27 +178,35 @@ export function getSubagentManualMarkdown(
152
178
  "",
153
179
  "## Async Mode",
154
180
  "",
155
- "Set `async: true` to run tasks in the background. The top-level `action` controls the ticket:",
181
+ "Set `async: true` to run tasks in the background. The top-level `ticketAction` controls the ticket:",
156
182
  "",
157
183
  "```ts",
158
184
  'delegate({ async: true, tasks: [{ prompt: "Investigate auth", systemPrompt: "You are a focused investigator.", tools: ["read", "grep", "find", "ls"] }] })',
159
185
  "```",
160
186
  "",
161
- '- `delegate({ action: "poll" })` \u2014 list all tickets',
162
- '- `delegate({ action: "poll", ticket: "abc123" })` \u2014 check one ticket',
163
- '- `delegate({ action: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
164
- '- `delegate({ action: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
165
- '- `delegate({ action: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
187
+ '- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
188
+ '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 check one ticket',
189
+ '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
190
+ '- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
191
+ '- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
166
192
  "",
167
193
  `See the field tables above for the full semantics. Max ${getMaxAsyncTickets()} concurrent async tickets.`,
194
+ "Async results arrive as follow-up messages, so Pi cannot fold their usage into the parent session total; displayed task usage remains informational.",
168
195
  "",
169
196
  "## Gotchas",
170
197
  "",
198
+ "- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
171
199
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
172
200
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
173
- "- An ad-hoc task with no `tools` uses `*`; a named task uses its profile; a profile with no tools uses `*`.",
201
+ '- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Omitting `agent` creates an ad-hoc task with delegate defaults.',
202
+ "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
174
203
  "- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
175
204
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
205
+ "- `deadlineMs` is a per-task wall-clock budget measured from when the task starts running (after queuing). It requests cooperative abort and is not a hard kill; completed writes/commands remain. Omission disables the deadline.",
206
+ "",
207
+ "## Legacy `action` compatibility",
208
+ "",
209
+ "The overloaded `action` field was split into `ticketAction` (poll/wait/cancel) and `sessionAction` (prompt/close/list). Legacy `action` values are still accepted at runtime through automatic normalization, but new calls should use the canonical fields. Programmatic TypeScript consumers should note the exported type `DelegateAction` is now `TicketAction`.",
176
210
  "",
177
211
  "## Config",
178
212
  "",
package/model.ts CHANGED
@@ -6,8 +6,7 @@ import { VALID_THINKING } from "./constants.ts";
6
6
  export interface ResolvedModelRequest {
7
7
  model: Model<Api> | undefined;
8
8
  /** Pi-style `:<thinking-level>` suffix stripped to make the reference
9
- * resolve. Reported so the caller can warn it is NOT honored as a
10
- * thinking level; the task's `thinking` field is the only thinking input. */
9
+ * resolve. The caller may use it as a last-resort thinking default. */
11
10
  strippedSuffix?: ThinkingLevel;
12
11
  }
13
12
 
@@ -32,8 +31,8 @@ function resolveModelReference(
32
31
  * stripped and the base reference is resolved — models learned this syntax
33
32
  * from Pi's CLI (e.g. `openai-codex/gpt-5.6-luna:max`) and keep emitting it,
34
33
  * so hard-failing the whole call over it is worse than tolerating it. The
35
- * suffix is deliberately NOT fed into thinking resolution: a single knob
36
- * (the `thinking` field) beats two knobs with a silent precedence rule. */
34
+ * suffix is returned separately so task resolution can use it as a
35
+ * last-resort default while keeping explicit `thinking` authoritative. */
37
36
  export function resolveModelRequest(
38
37
  spec: string | undefined,
39
38
  registry: ModelRegistry,
package/package.json CHANGED
@@ -1,25 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.0",
4
- "devDependencies": {
5
- "@earendil-works/pi-agent-core": "^0.80.9",
6
- "@earendil-works/pi-ai": "^0.80.9",
7
- "@earendil-works/pi-coding-agent": "^0.80.9",
8
- "@earendil-works/pi-tui": "^0.80.9",
9
- "@marcfargas/pi-test-harness": "^0.6.1",
10
- "@sinclair/typebox": "^0.34.0",
11
- "esbuild": "^0.27.0",
12
- "prettier": "^3.8.4",
13
- "typescript": "^5.9.0"
14
- },
15
- "private": false,
16
- "scripts": {
17
- "test": "bun test",
18
- "typecheck": "tsc --noEmit",
19
- "build": "esbuild delegate.ts --bundle --platform=neutral --packages=external --format=esm --banner:js=\"// @ts-nocheck\" --outfile=delegate.bundle.ts",
20
- "format": "prettier --write \"**/*.ts\""
21
- },
22
- "type": "module",
3
+ "version": "0.1.2",
23
4
  "description": "Delegate tool for the Pi coding agent.",
24
5
  "keywords": [
25
6
  "pi-package"
@@ -28,6 +9,7 @@
28
9
  "type": "git",
29
10
  "url": "git+https://github.com/bermudi/pi-delegate.git"
30
11
  },
12
+ "type": "module",
31
13
  "files": [
32
14
  "*.ts",
33
15
  "!*.test.ts",
@@ -39,5 +21,28 @@
39
21
  "extensions": [
40
22
  "./delegate.ts"
41
23
  ]
24
+ },
25
+ "devDependencies": {
26
+ "@earendil-works/pi-agent-core": "^0.80.9",
27
+ "@earendil-works/pi-ai": "^0.80.9",
28
+ "@earendil-works/pi-coding-agent": "^0.80.9",
29
+ "@earendil-works/pi-tui": "^0.80.9",
30
+ "@marcfargas/pi-test-harness": "^0.6.1",
31
+ "esbuild": "^0.27.0",
32
+ "prettier": "^3.8.4",
33
+ "typescript": "^5.9.0"
34
+ },
35
+ "patchedDependencies": {
36
+ "@marcfargas/pi-test-harness@0.6.1": "patches/@marcfargas%2Fpi-test-harness@0.6.1.patch"
37
+ },
38
+ "private": false,
39
+ "scripts": {
40
+ "test": "bun test",
41
+ "typecheck": "tsc --noEmit",
42
+ "build": "esbuild delegate.ts --bundle --platform=neutral --packages=external --format=esm --banner:js=\"// @ts-nocheck\" --outfile=.build/delegate.bundle.ts",
43
+ "format": "prettier --write \"**/*.ts\""
44
+ },
45
+ "dependencies": {
46
+ "@sinclair/typebox": "0.34.52"
42
47
  }
43
48
  }
@@ -0,0 +1,13 @@
1
+ diff --git a/dist/session.js b/dist/session.js
2
+ index f7dc5de9be959c541ee4bab0ad1424656993bbe9..e515073926f0fbb0e2bc566a9b17f5f15f80a1a1 100644
3
+ --- a/dist/session.js
4
+ +++ b/dist/session.js
5
+ @@ -12,7 +12,7 @@ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+ import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
9
+ -import { getModel } from "@earendil-works/pi-ai";
10
+ +import { getModel } from "@earendil-works/pi-ai/compat";
11
+ import { createPlaybookStreamFn } from "./playbook.js";
12
+ import { interceptToolExecution } from "./mock-tools.js";
13
+ import { createMockUIContext } from "./mock-ui.js";
package/pool.ts CHANGED
@@ -97,10 +97,25 @@ let poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
97
97
  * delegate calls with the same sessionId queue instead of interleaving. */
98
98
  const sessionLocks = new Map<string, Promise<void>>();
99
99
 
100
+ type PoolShutdownState = "open" | "closing" | "closed";
101
+ let poolState: PoolShutdownState = "open";
102
+ let closePromise: Promise<void> | null = null;
103
+
100
104
  function now(): number {
101
105
  return Date.now();
102
106
  }
103
107
 
108
+ function assertPoolOpenForNormalWork(): void {
109
+ if (poolState !== "open") {
110
+ throw new Error("Session pool is not accepting new work.");
111
+ }
112
+ }
113
+
114
+ function waitForActiveSessionLocks(): Promise<void> {
115
+ const locks = [...sessionLocks.values()];
116
+ return Promise.all(locks).then(() => undefined);
117
+ }
118
+
104
119
  // ── Read + validate ───────────────────────────────────────────────────────
105
120
 
106
121
  /** Look up a pooled session and validate a reuse request against its frozen
@@ -115,8 +130,8 @@ function now(): number {
115
130
  * - mismatch → pooled but its immutable configuration conflicts with this
116
131
  * request. Caller formats the structured diff into an error.
117
132
  *
118
- * lastUsed is bumped by commit() on a successful run, not here — so a checkout
119
- * that bails (e.g. a resumeFrom conflict at the caller) does not affect stats. */
133
+ * lastUsed is bumped by recordUse() after a completed pool hit, not here — so
134
+ * a checkout that bails (e.g. a resumeFrom conflict) does not affect stats. */
120
135
  export function checkout(
121
136
  sessionId: string,
122
137
  candidate: ConfigCandidate,
@@ -187,28 +202,26 @@ export function checkout(
187
202
 
188
203
  // ── The sole mutator (besides close) ──────────────────────────────────────
189
204
 
190
- /** Record the outcome of a run against a sessionId. Decides insert-vs-recordUse
191
- * internally by map presence:
192
- * - present (pool hit) → bump lastUsed, totalTokens, promptCount.
193
- * - absent (fresh/resume success) → insert with the frozen config.
205
+ /**
206
+ * Insert the first successful prompt for a fresh/resumed session into the pool.
194
207
  *
195
- * MUST be called inside withSessionLock(sessionId, …) the map-presence
196
- * decision is sound only because the lock serializes same-sessionId tasks, so
197
- * no concurrent commit can race the insert. MUST only be called on run success
198
- * (insert-only-on-success is caller-gated). Returns true when the session is
199
- * now pool-owned, false when a fresh session could not be inserted because it
200
- * lacks the manager/file required by a pooled entry. */
208
+ * MUST be called inside withSessionLock(sessionId, …) and only when this run
209
+ * should transfer ownership from lifecycle to the pool. Returns true when the
210
+ * session is now pool-owned, false when insertion is blocked (shutdown) or
211
+ * impossible (missing manager/file).
212
+ */
201
213
  export function commit(sessionId: string, payload: CommitPayload): boolean {
202
- const existing = agentPool.get(sessionId);
203
- if (existing) {
204
- // Pool hit: session already pooled, just bump stats.
205
- existing.lastUsed = now();
206
- existing.totalTokens += payload.tokens;
207
- existing.promptCount++;
208
- return true;
214
+ // Shutdown-aware policy: once shutdown has started, inserting a new pool entry
215
+ // is unsafe. The lifecycle handles the still-owned session (abort/cleanup) and
216
+ // should dispose it instead of inserting it after the barrier begins.
217
+ if (poolState !== "open") {
218
+ return false;
209
219
  }
220
+
210
221
  // Miss → success: insert. A pooled entry needs a concrete file/manager.
211
222
  if (!payload.sessionManager || !payload.sessionFile) return false;
223
+
224
+ if (agentPool.has(sessionId)) return false;
212
225
  agentPool.set(sessionId, {
213
226
  session: payload.session,
214
227
  sessionManager: payload.sessionManager,
@@ -222,6 +235,20 @@ export function commit(sessionId: string, payload: CommitPayload): boolean {
222
235
  return true;
223
236
  }
224
237
 
238
+ /** Record a completed run against an existing pooled session. This is separate
239
+ * from commit() so lifecycle can distinguish ownership transfer from hit
240
+ * accounting. Returns true only when the sessionId is currently pooled.
241
+ */
242
+ export function recordUse(sessionId: string, tokens: number): boolean {
243
+ const existing = agentPool.get(sessionId);
244
+ if (!existing) return false;
245
+
246
+ existing.lastUsed = now();
247
+ existing.totalTokens += tokens;
248
+ existing.promptCount++;
249
+ return true;
250
+ }
251
+
225
252
  // ── Read-only defaults (for task-resolution) ──────────────────────────────
226
253
 
227
254
  /** Frozen config for a pooled session, or undefined if not pooled. Lock-free —
@@ -242,10 +269,26 @@ export function configFor(
242
269
  * (lifecycle) can bracket the ENTIRE acquire/run/commit flow; checkout/commit
243
270
  * do NOT lock internally because their only caller is already inside this
244
271
  * bracket. Close is also invoked under this lock, so it never disposes an
245
- * in-flight prompt. */
272
+ * in-flight prompt.
273
+ *
274
+ * External callers must go through this exported path only while the pool is
275
+ * open. Internal callers that are already synchronized by a higher-level lock
276
+ * can use `withSessionLockInternal`.
277
+ */
246
278
  export async function withSessionLock<T>(
247
279
  sessionId: string,
248
280
  fn: () => Promise<T>,
281
+ ): Promise<T> {
282
+ assertPoolOpenForNormalWork();
283
+ return withSessionLockInternal(sessionId, fn);
284
+ }
285
+
286
+ /** Internal lock variant that does not reject during shutdown. Use only from
287
+ * lifecycle/control paths that already account for pool shutdown state.
288
+ */
289
+ async function withSessionLockInternal<T>(
290
+ sessionId: string,
291
+ fn: () => Promise<T>,
249
292
  ): Promise<T> {
250
293
  const prev = sessionLocks.get(sessionId);
251
294
  let resolve!: () => void;
@@ -303,8 +346,8 @@ function beginAbort(session: AgentSession): Promise<AbortOutcome> {
303
346
  }
304
347
 
305
348
  /** Close and dispose one pooled session. A caller holds the per-session lock
306
- * while closing, so abort cannot race a reuse. All cleanup is attempted before
307
- * an error is surfaced; a removed session is never silently retained. */
349
+ * while closing, so abort cannot race a reuse. All cleanup is attempted before
350
+ * an error is surfaced; a removed session is never silently retained. */
308
351
  async function closePooledAgentAfterAbort(
309
352
  sessionId: string,
310
353
  abort: Promise<AbortOutcome>,
@@ -346,44 +389,117 @@ async function closePooledAgentAfterAbort(
346
389
  return true;
347
390
  }
348
391
 
349
- /** Abort, dispose, and remove one pooled session. Returns false when the id
350
- * is already absent; cleanup failures are aggregated after removal. */
351
- export async function closePooledAgent(sessionId: string): Promise<boolean> {
392
+ /** Internal close helper for callers that already own (or are obtaining) the
393
+ * session lock. */
394
+ async function closePooledAgentAfterLock(sessionId: string): Promise<boolean> {
352
395
  const pooled = agentPool.get(sessionId);
353
396
  if (!pooled) return false;
354
397
  return closePooledAgentAfterAbort(sessionId, beginAbort(pooled.session));
355
398
  }
356
399
 
400
+ /** @internal Close while the caller already owns the per-session lock. */
401
+ export async function _closePooledAgentWithoutLock(
402
+ sessionId: string,
403
+ ): Promise<boolean> {
404
+ return closePooledAgentAfterLock(sessionId);
405
+ }
406
+
407
+ /** Abort, dispose, and remove one pooled session. Returns false when the id
408
+ * is already absent; cleanup failures are aggregated after removal.
409
+ * During shutdown or after close, this waits for shutdown to finish and
410
+ * returns `false` instead of throwing.
411
+ */
412
+ export async function closePooledAgent(sessionId: string): Promise<boolean> {
413
+ if (poolState !== "open") {
414
+ const existing = closePromise;
415
+ if (existing) {
416
+ try {
417
+ await existing;
418
+ } catch {
419
+ // Keep public close idempotent during and after shutdown.
420
+ }
421
+ }
422
+ return false;
423
+ }
424
+
425
+ let awaitShutdown: Promise<void> | undefined;
426
+ let closed = false;
427
+
428
+ await withSessionLockInternal(sessionId, async () => {
429
+ // A shutdown can begin while this call waits for its lock. Avoid waiting
430
+ // for closePromise inside this lock: do that work after releasing it, so
431
+ // closeAll can acquire the lock and dispose the same session.
432
+ if (poolState !== "open") {
433
+ awaitShutdown = closePromise ?? undefined;
434
+ return;
435
+ }
436
+ closed = await closePooledAgentAfterLock(sessionId);
437
+ });
438
+
439
+ if (awaitShutdown) {
440
+ try {
441
+ await awaitShutdown;
442
+ } catch {
443
+ // Keep public close idempotent during and after shutdown.
444
+ }
445
+ }
446
+
447
+ return closed;
448
+ }
449
+
357
450
  /** Dispose every live pooled session when the parent Pi session ends. First
358
- * request cancellation immediately, then acquire each session's lock before
359
- * disposal. The lock prevents an in-flight lifecycle from committing a live
360
- * session after shutdown has removed it. Attempts all cleanup before reporting
361
- * any failures. */
451
+ * request cancellation immediately, then wait for all in-flight session locks,
452
+ * then acquire each remaining session's lock before disposal. Attempts are
453
+ * executed for all sessions before reporting failures. Idempotent callers share
454
+ * the same completion promise, including failures.
455
+ */
362
456
  export async function closeAllPooledAgents(): Promise<void> {
363
- const aborts = new Map<string, Promise<AbortOutcome>>(
364
- [...agentPool].map(([sessionId, pooled]) => [
365
- sessionId,
366
- beginAbort(pooled.session),
367
- ]),
368
- );
369
- const results = await Promise.allSettled(
370
- [...aborts].map(([sessionId, abort]) =>
371
- withSessionLock(sessionId, () =>
372
- closePooledAgentAfterAbort(sessionId, abort),
457
+ if (closePromise) {
458
+ return closePromise;
459
+ }
460
+ if (poolState === "closed") {
461
+ const completed = Promise.resolve();
462
+ closePromise = completed;
463
+ return completed;
464
+ }
465
+
466
+ poolState = "closing";
467
+
468
+ const completion = (async () => {
469
+ const aborts = new Map<string, Promise<AbortOutcome>>(
470
+ [...agentPool].map(([sessionId, pooled]) => [
471
+ sessionId,
472
+ beginAbort(pooled.session),
473
+ ]),
474
+ );
475
+
476
+ await waitForActiveSessionLocks();
477
+
478
+ const results = await Promise.allSettled(
479
+ [...aborts].map(([sessionId, abort]) =>
480
+ withSessionLockInternal(sessionId, () =>
481
+ closePooledAgentAfterAbort(sessionId, abort),
482
+ ),
373
483
  ),
374
- ),
375
- );
376
- const failures = results
377
- .filter(
378
- (result): result is PromiseRejectedResult => result.status === "rejected",
379
- )
380
- .map((result) => result.reason);
381
- if (failures.length) {
382
- throw new AggregateError(
383
- failures,
384
- "Failed to close one or more pooled sessions.",
385
484
  );
386
- }
485
+ const failures = results
486
+ .filter(
487
+ (result): result is PromiseRejectedResult =>
488
+ result.status === "rejected",
489
+ )
490
+ .map((result) => result.reason);
491
+ if (failures.length) {
492
+ throw new AggregateError(
493
+ failures,
494
+ "Failed to close one or more pooled sessions.",
495
+ );
496
+ }
497
+ })();
498
+
499
+ closePromise = completion.finally(() => {
500
+ poolState = "closed";
501
+ });
502
+ return closePromise;
387
503
  }
388
504
 
389
505
  /** List live pooled agents. Sessions remain available until explicit close or
@@ -416,5 +532,7 @@ export function _setPoolAbortTimeoutForTesting(
416
532
  export function _resetPoolForTesting(): void {
417
533
  agentPool.clear();
418
534
  sessionLocks.clear();
535
+ poolState = "open";
536
+ closePromise = null;
419
537
  poolAbortTimeoutMs = DEFAULT_POOL_ABORT_TIMEOUT_MS;
420
538
  }
@@ -1,8 +1,9 @@
1
- import { Markdown } from "@earendil-works/pi-tui";
1
+ import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
2
2
  import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  fmtDuration,
5
5
  fmtTokens,
6
+ formatTaskId,
6
7
  getActivityAge,
7
8
  indent,
8
9
  tree,
@@ -17,6 +18,25 @@ import { stripAnsi, resolveCarriageReturn } from "./utils.ts";
17
18
  import { getMaxConcurrent } from "./config.ts";
18
19
  import type { TaskProgress, TaskResult } from "./types.ts";
19
20
 
21
+ /**
22
+ * Render markdown output when a compatible host theme hook exists. If the host
23
+ * does not expose `getMarkdownTheme`, return plain text lines as a safe fallback
24
+ * so a single missing host hook cannot crash the renderer.
25
+ */
26
+ function renderOutputLines(raw: string, width: number): string[] {
27
+ const trimmed = raw.trim();
28
+ if (!trimmed) return [];
29
+ try {
30
+ if (typeof getMarkdownTheme !== "function") return trimmed.split("\n");
31
+ const theme = getMarkdownTheme();
32
+ if (typeof theme !== "object" || !theme) return trimmed.split("\n");
33
+ const md = new Markdown(trimmed, 0, 0, theme as MarkdownTheme);
34
+ return md.render(width);
35
+ } catch (_error) {
36
+ return trimmed.split("\n");
37
+ }
38
+ }
39
+
20
40
  /** Renderer state — the live subset of Pi's `ToolRenderContext.state`. */
21
41
  export interface RenderState {
22
42
  startedAt?: number;
@@ -88,7 +108,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
88
108
  case "done":
89
109
  lines.push(
90
110
  truncLine(
91
- `${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
111
+ `${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
92
112
  w,
93
113
  ),
94
114
  );
@@ -107,7 +127,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
107
127
  case "failed":
108
128
  lines.push(
109
129
  truncLine(
110
- `${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
130
+ `${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
111
131
  w,
112
132
  ),
113
133
  );
@@ -127,14 +147,19 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
127
147
  {
128
148
  const activityAge = getActivityAge(p.lastActivityAt);
129
149
  const ageTag = activityAge ? ` · ${activityAge}` : "";
130
- const stallTag =
131
- p.failureKind === "stalled"
132
- ? theme.fg("warning", " · stall detected · cancellation pending")
133
- : "";
150
+ const issueTag =
151
+ p.failureKind === "deadline_exceeded"
152
+ ? theme.fg("error", " · deadline exceeded · cancellation pending")
153
+ : p.failureKind === "stalled"
154
+ ? theme.fg(
155
+ "warning",
156
+ " · stall detected · cancellation pending",
157
+ )
158
+ : "";
134
159
  const glyph = theme.fg("warning", spinnerFrame());
135
160
  lines.push(
136
161
  truncLine(
137
- `${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin(runParts)}${stallTag}${theme.fg("muted", ageTag)}`,
162
+ `${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin(runParts)}${issueTag}${theme.fg("muted", ageTag)}`,
138
163
  w,
139
164
  ),
140
165
  );
@@ -210,7 +235,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
210
235
  );
211
236
  lines.push(
212
237
  truncLine(
213
- `${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${modelLabel(p)} ${queuedTag}`,
238
+ `${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)} ${queuedTag}`,
214
239
  w,
215
240
  ),
216
241
  );
@@ -241,12 +266,18 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
241
266
  const totalTokens = progress.reduce((sum, p) => sum + p.tokens, 0);
242
267
  const ticketId = ctx.ticketId;
243
268
  const ticketStatus = ctx.ticketStatus;
244
- const isLive = ticketStatus === "running" || ticketStatus === "cancelling";
269
+ // A terminal ticket can retain a stale running/pending row while its workers
270
+ // unwind. Keep the row presentation terminal in that case; an absent status
271
+ // is the synchronous-render path, where task status remains authoritative.
272
+ const ticketIsLive =
273
+ ticketStatus === undefined ||
274
+ ticketStatus === "running" ||
275
+ ticketStatus === "cancelling";
245
276
  const elapsed = state.startedAt
246
277
  ? fmtDuration(Date.now() - state.startedAt)
247
278
  : fmtDuration(progress.reduce((sum, p) => sum + p.durationMs, 0));
248
279
 
249
- if (ticketId && isLive) {
280
+ if (ticketId && ticketIsLive) {
250
281
  // Background ticket — frame it as in-progress, not a finished result.
251
282
  const ticketParts = [
252
283
  `ticket ${ticketId}`,
@@ -288,9 +319,15 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
288
319
  : p.status === "running"
289
320
  ? theme.fg("warning", "◐")
290
321
  : theme.fg("muted", "○");
291
- const taskPreview = theme.fg("muted", trunc(p.task, w - 30));
322
+ const taskId = p.id ? formatTaskId(p.id) : "";
323
+ const taskIdTag = p.id ? theme.fg("accent", taskId) : "";
324
+ const taskIdWidth = p.id ? taskId.length : 0;
325
+ const previewBudget = Math.max(1, w - 30 - taskIdWidth);
326
+ const taskPreview = theme.fg("muted", trunc(p.task, previewBudget));
292
327
  const isLive =
293
- p.status === "running" || (p.status === "pending" && !isCancelledPending);
328
+ ticketIsLive &&
329
+ (p.status === "running" ||
330
+ (p.status === "pending" && !isCancelledPending));
294
331
  // Live tasks show an activity/waiting hint instead of final stats.
295
332
  const liveTail =
296
333
  p.status === "running"
@@ -303,7 +340,7 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
303
340
  : "";
304
341
  lines.push(
305
342
  truncLine(
306
- `${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
343
+ `${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)}${taskIdTag} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
307
344
  w,
308
345
  ),
309
346
  );
@@ -365,8 +402,7 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
365
402
  let mdLines: string[] | undefined = state[cacheKey] as
366
403
  string[] | undefined;
367
404
  if (!mdLines || state[`${cacheKey}_src`] !== r.output) {
368
- const md = new Markdown(r.output.trim(), 0, 0, getMarkdownTheme());
369
- mdLines = md.render(Math.max(20, w - ind.length));
405
+ mdLines = renderOutputLines(r.output, Math.max(20, w - ind.length));
370
406
  state[`${cacheKey}_src`] = r.output;
371
407
  state[cacheKey] = mdLines;
372
408
  }
package/render-result.ts CHANGED
@@ -166,6 +166,18 @@ export function renderDelegateResult(
166
166
  ticketStatus,
167
167
  };
168
168
 
169
+ // Surface the touched-file overlap warning at the top of the TUI. The same
170
+ // text already lives in the textual content, but the progress-based renderer
171
+ // ignores content, so we must render it explicitly from details. Rendering it
172
+ // before the progress tree places it at the top of the budgeted region, so it
173
+ // survives truncation from the bottom when many tasks collapse the view.
174
+ if (details?.overlapWarning) {
175
+ lines.push(
176
+ truncLine(theme.fg("warning", `⚠ ${details.overlapWarning}`), w),
177
+ "",
178
+ );
179
+ }
180
+
169
181
  if (options.isPartial) {
170
182
  renderPartialBranch(branchCtx, helpers);
171
183
  } else {