@bermudi/pi-delegate 0.1.13 → 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 +1 -1
- 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/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
|
@@ -251,6 +251,6 @@ export function getSubagentManualMarkdown(
|
|
|
251
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.",
|
|
252
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.",
|
|
253
253
|
"",
|
|
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;
|
|
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.`,
|
|
255
255
|
].join("\n");
|
|
256
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;
|