@bermudi/pi-delegate 0.1.12 → 0.1.14
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 +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +24 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +79 -20
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +555 -80
- package/manual.ts +10 -7
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +232 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/schema.ts +2 -2
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +47 -48
- package/telemetry.ts +1 -1
- package/ticket-format.ts +8 -3
- package/tickets.ts +33 -13
- package/tools.ts +5 -4
- package/types.ts +77 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
package/manual.ts
CHANGED
|
@@ -120,6 +120,8 @@ export function getSubagentManualMarkdown(
|
|
|
120
120
|
"",
|
|
121
121
|
"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.",
|
|
122
122
|
"",
|
|
123
|
+
"Vocabulary, since 'agent' does double duty elsewhere: a **task** is one job; an **agent** is a named profile — a saved bundle of system prompt, tools, workspace, and model policy — that a task references by `agent` name; a **subagent** is the live session that executes one task. Agents are templates, not processes; nothing runs until a task dispatches.",
|
|
124
|
+
"",
|
|
123
125
|
"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`.",
|
|
124
126
|
"",
|
|
125
127
|
"## Touched Files (best-effort)",
|
|
@@ -142,7 +144,7 @@ export function getSubagentManualMarkdown(
|
|
|
142
144
|
"",
|
|
143
145
|
agentList,
|
|
144
146
|
"",
|
|
145
|
-
"Custom agents are defined either inline in a task (using `systemPrompt`, `tools`, and `thinking`) or persisted as Markdown files in `.pi/agents/*.md` (project-local), `~/.pi/agent/agents/` (global), and `.claude/agents/` (interchange with Claude Code).
|
|
147
|
+
"Custom agents are profiles, defined either inline in a task (using `systemPrompt`, `tools`, and `thinking`) or persisted as Markdown files in `.pi/agents/*.md` (project-local), `~/.pi/agent/agents/` (global), and `.claude/agents/` (interchange with Claude Code). Inline definitions let the parent model shape the subagent it needs on each call. Each Markdown file is one profile with YAML frontmatter:",
|
|
146
148
|
"",
|
|
147
149
|
"```markdown",
|
|
148
150
|
"---",
|
|
@@ -165,26 +167,26 @@ export function getSubagentManualMarkdown(
|
|
|
165
167
|
"## Session Reuse",
|
|
166
168
|
"",
|
|
167
169
|
"When `sessionId` is set, the subagent is kept alive in a pool for the duration of the pi session.",
|
|
168
|
-
"Subsequent calls with the same `sessionId` continue the conversation — the
|
|
170
|
+
"Subsequent calls with the same `sessionId` continue the conversation — the session retains prior context.",
|
|
169
171
|
"",
|
|
170
172
|
"```ts",
|
|
171
173
|
"// First call — creates and runs an inline custom agent",
|
|
172
174
|
'delegate({ tasks: [{ prompt: "Investigate the auth module", systemPrompt: "You are a focused investigator. Map files and dependencies.", tools: ["read", "grep", "find", "ls"], sessionId: "auth-research" }] })',
|
|
173
175
|
"",
|
|
174
|
-
"// Second call — continues the same
|
|
176
|
+
"// Second call — continues the same subagent",
|
|
175
177
|
'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
|
|
176
178
|
"",
|
|
177
179
|
"// Clean up when done",
|
|
178
180
|
'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
|
|
179
181
|
"```",
|
|
180
182
|
"",
|
|
181
|
-
'Pooled
|
|
183
|
+
'Pooled sessions remain live until `sessionAction: "close"` or parent Pi session shutdown.',
|
|
182
184
|
"",
|
|
183
185
|
"## Resuming Previous Sessions",
|
|
184
186
|
"",
|
|
185
187
|
"Use `resumeFrom` to continue a failed or interrupted subagent from where it left off.",
|
|
186
188
|
"Pass the exact absolute path to the session `.jsonl` file copied from delegate retry output. Do not invent placeholder values or use it as a ticket ID; async resume is supported.",
|
|
187
|
-
"The
|
|
189
|
+
"The subagent gets the full conversation history and the new `prompt` continues naturally.",
|
|
188
190
|
"",
|
|
189
191
|
"```ts",
|
|
190
192
|
"// Copy this exact path from the failed delegate result; do not invent it.",
|
|
@@ -193,7 +195,7 @@ export function getSubagentManualMarkdown(
|
|
|
193
195
|
" resumeFrom: exactRetrySessionFile }] })",
|
|
194
196
|
"```",
|
|
195
197
|
"",
|
|
196
|
-
"Combine with `sessionId` to resume AND pool the
|
|
198
|
+
"Combine with `sessionId` to resume AND pool the subagent for further multi-turn use:",
|
|
197
199
|
"",
|
|
198
200
|
"```ts",
|
|
199
201
|
'delegate({ tasks: [{ prompt: "Continue the investigation",',
|
|
@@ -238,6 +240,7 @@ export function getSubagentManualMarkdown(
|
|
|
238
240
|
"- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
|
|
239
241
|
'- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
|
|
240
242
|
'- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Built-ins are `default`, `scout`, `coder`, and `reviewer`; omitting `agent` creates an ad-hoc task.',
|
|
243
|
+
"- Omit `thinking` for named agents unless the user asks or the task clearly needs escalation — task-level `thinking` overrides every configured tier (`agentOverrides`, `agentOverridesByParentModel`, Markdown frontmatter, `:level` model suffix), so a casual value silently defeats the configured budget. Ad-hoc tasks default to the parent's thinking.",
|
|
241
244
|
"- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
|
|
242
245
|
"- 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.",
|
|
243
246
|
`- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
|
|
@@ -248,6 +251,6 @@ export function getSubagentManualMarkdown(
|
|
|
248
251
|
"Tunables live in `~/.pi/agent/delegate.json` (user scope, global — no project-level config): `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, legacy custom-agent model overrides (`agent`), and agent model/thinking/tools overrides — `agentOverrides` and `agentOverridesByParentModel` (exact `provider/model-id` key of the parent model; wins over `agentOverrides` on match). Config edits apply from the next delegate call.",
|
|
249
252
|
"The inactivity watchdog requests cooperative `AgentSession.abort()` cancellation and waits for the subagent to become idle; it is not a hard wall-clock execution deadline.",
|
|
250
253
|
"",
|
|
251
|
-
`Output bounding: subagent outputs longer than ${OUTPUT_SPILL_THRESHOLD_CHARS} characters are spilled to a temp file, and only the last ${OUTPUT_SPILL_TAIL_CHARS} characters stay in the LLM-facing result. Adjust with \`output.spillThresholdChars\` and \`output.spillTailChars\`. Spill files are written to the system temp directory with owner-only permissions;
|
|
254
|
+
`Output bounding: subagent outputs longer than ${OUTPUT_SPILL_THRESHOLD_CHARS} characters are spilled to a temp file, and only the last ${OUTPUT_SPILL_TAIL_CHARS} characters stay in the LLM-facing result. Adjust with \`output.spillThresholdChars\` and \`output.spillTailChars\`. Spill files are written to the system temp directory with owner-only permissions and follow OS temp lifecycle; full output remains in the expanded TUI view.`,
|
|
252
255
|
].join("\n");
|
|
253
256
|
}
|
package/model.ts
CHANGED
|
@@ -76,5 +76,10 @@ export function findAvailableAlternative(
|
|
|
76
76
|
// Prefer a different provider (avoid returning the same broken model).
|
|
77
77
|
return registry
|
|
78
78
|
.getAvailable()
|
|
79
|
-
.find(
|
|
79
|
+
.find(
|
|
80
|
+
(m) =>
|
|
81
|
+
m.id === model.id &&
|
|
82
|
+
m.provider !== model.provider &&
|
|
83
|
+
registry.hasConfiguredAuth(m),
|
|
84
|
+
);
|
|
80
85
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bermudi/pi-delegate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "Delegate tool for the Pi coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -23,14 +23,14 @@
|
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
27
|
-
"@earendil-works/pi-ai": "^0.
|
|
28
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
29
|
-
"@earendil-works/pi-tui": "^0.
|
|
26
|
+
"@earendil-works/pi-agent-core": "^0.84.2",
|
|
27
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
29
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
30
30
|
"@marcfargas/pi-test-harness": "^0.6.1",
|
|
31
|
-
"esbuild": "^0.
|
|
32
|
-
"prettier": "^3.
|
|
33
|
-
"typescript": "^5.9.
|
|
31
|
+
"esbuild": "^0.28.2",
|
|
32
|
+
"prettier": "^3.9.6",
|
|
33
|
+
"typescript": "^5.9.3"
|
|
34
34
|
},
|
|
35
35
|
"patchedDependencies": {
|
|
36
36
|
"@marcfargas/pi-test-harness@0.6.1": "patches/@marcfargas%2Fpi-test-harness@0.6.1.patch"
|
|
@@ -40,9 +40,13 @@
|
|
|
40
40
|
"test": "bun test",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
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\""
|
|
43
|
+
"format": "prettier --write \"**/*.ts\"",
|
|
44
|
+
"format:check": "prettier --check \"**/*.ts\""
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@sinclair/typebox": "0.34.52"
|
|
48
|
+
},
|
|
49
|
+
"overrides": {
|
|
50
|
+
"brace-expansion": "5.0.9"
|
|
47
51
|
}
|
|
48
52
|
}
|
package/pool.ts
CHANGED
|
@@ -128,6 +128,21 @@ function waitForActiveSessionLocks(): Promise<void> {
|
|
|
128
128
|
return Promise.all(locks).then(() => undefined);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
|
|
132
|
+
if (typeof value !== "object" || value === null || seen.has(value)) {
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
seen.add(value);
|
|
136
|
+
const record = value as Record<PropertyKey, unknown>;
|
|
137
|
+
for (const key of Reflect.ownKeys(value)) deepFreeze(record[key], seen);
|
|
138
|
+
return Object.freeze(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Defensive value copy for the capability configuration crossing the pool seam. */
|
|
142
|
+
function cloneFrozenConfig(config: FrozenConfig): FrozenConfig {
|
|
143
|
+
return deepFreeze(structuredClone(config));
|
|
144
|
+
}
|
|
145
|
+
|
|
131
146
|
// ── Read + validate ───────────────────────────────────────────────────────
|
|
132
147
|
|
|
133
148
|
/** Look up a pooled session and validate a reuse request against its frozen
|
|
@@ -248,7 +263,7 @@ export function commit(sessionId: string, payload: CommitPayload): boolean {
|
|
|
248
263
|
session: payload.session,
|
|
249
264
|
sessionManager: payload.sessionManager,
|
|
250
265
|
sessionFile: payload.sessionFile,
|
|
251
|
-
config: payload.frozen,
|
|
266
|
+
config: cloneFrozenConfig(payload.frozen),
|
|
252
267
|
lastUsed: now(),
|
|
253
268
|
createdAt: now(),
|
|
254
269
|
totalTokens: payload.tokens,
|
|
@@ -271,17 +286,33 @@ export function recordUse(sessionId: string, tokens: number): boolean {
|
|
|
271
286
|
return true;
|
|
272
287
|
}
|
|
273
288
|
|
|
289
|
+
/** Remove an exact live session from reuse without aborting or disposing it.
|
|
290
|
+
* Lifecycle uses this only after runner reports quiescence abandonment: any
|
|
291
|
+
* ordinary pool close would race provider/extension work that may still be
|
|
292
|
+
* running. The caller retains the detached session until its background safety
|
|
293
|
+
* promise resolves. */
|
|
294
|
+
export function _quarantinePooledAgentWithoutDisposal(
|
|
295
|
+
sessionId: string,
|
|
296
|
+
expectedSession: AgentSession,
|
|
297
|
+
): boolean {
|
|
298
|
+
const existing = agentPool.get(sessionId);
|
|
299
|
+
if (!existing || existing.session !== expectedSession) return false;
|
|
300
|
+
agentPool.delete(sessionId);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
274
304
|
// ── Read-only defaults (for task-resolution) ──────────────────────────────
|
|
275
305
|
|
|
276
306
|
/** Frozen config for a pooled session, or undefined if not pooled. Lock-free —
|
|
277
|
-
* safe because the
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* thinking, tools} for a task that supplies only a sessionId. */
|
|
307
|
+
* safe because the stored config is a deeply frozen defensive copy. Callers
|
|
308
|
+
* receive another frozen copy so values crossing the pool seam cannot mutate
|
|
309
|
+
* its capability contract. Used by resolveTasks to default {systemPrompt,
|
|
310
|
+
* model, thinking, tools} for a task that supplies only a sessionId. */
|
|
281
311
|
export function configFor(
|
|
282
312
|
sessionId: string,
|
|
283
313
|
): Readonly<FrozenConfig> | undefined {
|
|
284
|
-
|
|
314
|
+
const config = agentPool.get(sessionId)?.config;
|
|
315
|
+
return config ? cloneFrozenConfig(config) : undefined;
|
|
285
316
|
}
|
|
286
317
|
|
|
287
318
|
// ── Lock primitive (D1) ───────────────────────────────────────────────────
|
package/quiescence.ts
CHANGED
|
@@ -47,9 +47,11 @@
|
|
|
47
47
|
* Once cancellation has been requested the session is supposed to be tearing
|
|
48
48
|
* down, so the wait is bounded by `cancelledUnwindBudgetMs`. Without a bound,
|
|
49
49
|
* an extension that keeps launching continuations keeps resetting progress and
|
|
50
|
-
* the barrier never returns — hanging the delegate task forever
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* the barrier never returns — hanging the delegate task forever. On expiry the
|
|
51
|
+
* barrier logs and returns `"abandoned"`. Runner then transfers a private
|
|
52
|
+
* quarantine marker and an unbounded background termination promise to
|
|
53
|
+
* lifecycle; lifecycle must detach the session and defer disposal/workspace
|
|
54
|
+
* cleanup until that promise confirms quiescence.
|
|
53
55
|
*/
|
|
54
56
|
|
|
55
57
|
/** Why the runner asked the session to stop. */
|
|
@@ -91,8 +93,8 @@ export const DEFAULT_QUIESCENCE_TIMINGS: QuiescenceTimings = {
|
|
|
91
93
|
/**
|
|
92
94
|
* `"quiescent"` — the session went idle and stayed quiet; ownership may be
|
|
93
95
|
* returned to the caller. `"abandoned"` — the cancelled-unwind budget expired
|
|
94
|
-
* while work was still starting; the caller
|
|
95
|
-
*
|
|
96
|
+
* while work was still starting; the caller must quarantine the session because
|
|
97
|
+
* it may still be active.
|
|
96
98
|
*/
|
|
97
99
|
export type QuiescenceOutcome = "quiescent" | "abandoned";
|
|
98
100
|
|
|
@@ -104,6 +106,9 @@ export type QuiescenceBarrierOptions = {
|
|
|
104
106
|
cancel: (source: CancellationSource) => void;
|
|
105
107
|
timings?: Partial<QuiescenceTimings>;
|
|
106
108
|
now?: () => number;
|
|
109
|
+
/** Do not let liveness probes keep Node alive. Used only by the unbounded
|
|
110
|
+
* quarantine recovery monitor; foreground barriers retain ordinary timers. */
|
|
111
|
+
unrefTimers?: boolean;
|
|
107
112
|
/** Overridable for tests; defaults to a `console.error` trace. */
|
|
108
113
|
onAbandon?: (info: {
|
|
109
114
|
source: CancellationSource;
|
|
@@ -161,7 +166,12 @@ export function createQuiescenceBarrier(
|
|
|
161
166
|
};
|
|
162
167
|
|
|
163
168
|
const sleep = (ms: number) =>
|
|
164
|
-
new Promise<void>((resolve) =>
|
|
169
|
+
new Promise<void>((resolve) => {
|
|
170
|
+
const timer = setTimeout(resolve, ms);
|
|
171
|
+
if (options.unrefTimers && typeof timer.unref === "function") {
|
|
172
|
+
timer.unref();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
165
175
|
const nextEventLoopTurn = () =>
|
|
166
176
|
new Promise<void>((resolve) => setImmediate(resolve));
|
|
167
177
|
|
|
@@ -178,6 +188,9 @@ export function createQuiescenceBarrier(
|
|
|
178
188
|
resolve();
|
|
179
189
|
};
|
|
180
190
|
const probe = setTimeout(finish, probeMs);
|
|
191
|
+
if (options.unrefTimers && typeof probe.unref === "function") {
|
|
192
|
+
probe.unref();
|
|
193
|
+
}
|
|
181
194
|
if (generation !== sampled) {
|
|
182
195
|
finish();
|
|
183
196
|
return;
|