@bermudi/pi-delegate 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -10
- package/agents.ts +176 -19
- package/concurrency.ts +70 -7
- package/constants.ts +3 -0
- package/delegate.ts +26 -1
- package/dispatch.ts +65 -9
- package/extension.ts +86 -6
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/leaf.ts +48 -0
- package/lifecycle.ts +292 -95
- package/manual.ts +45 -11
- package/model.ts +3 -4
- package/package.json +25 -20
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/pool.ts +169 -51
- package/render-branches.ts +52 -16
- package/render-result.ts +12 -0
- package/runner.ts +255 -51
- package/schema.ts +252 -63
- package/status.ts +269 -0
- package/task-resolution.ts +196 -77
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +62 -13
package/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
|
|
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",
|
|
141
|
+
'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
|
|
116
142
|
"```",
|
|
117
143
|
"",
|
|
118
|
-
'Pooled agents remain live until `
|
|
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 `
|
|
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({
|
|
162
|
-
'- `delegate({
|
|
163
|
-
'- `delegate({
|
|
164
|
-
'- `delegate({
|
|
165
|
-
'- `delegate({
|
|
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
|
-
|
|
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.
|
|
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
|
|
36
|
-
*
|
|
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.
|
|
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
|
|
119
|
-
* that bails (e.g. a resumeFrom conflict
|
|
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
|
-
/**
|
|
191
|
-
*
|
|
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, …)
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* (
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
*
|
|
307
|
-
*
|
|
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
|
-
/**
|
|
350
|
-
*
|
|
351
|
-
|
|
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
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
}
|
package/render-branches.ts
CHANGED
|
@@ -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
|
|
131
|
-
p.failureKind === "
|
|
132
|
-
? theme.fg("
|
|
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)}${
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|