@bermudi/pi-delegate 0.1.13 → 0.1.15

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
@@ -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; the full output is always available in the expanded TUI view and the spilled file.`,
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((m) => m.id === model.id && m.provider !== model.provider);
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.13",
3
+ "version": "0.1.15",
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.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",
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.27.0",
32
- "prettier": "^3.8.4",
33
- "typescript": "^5.9.0"
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 frozen config is write-only at insert and never mutated
278
- * thereafter (only stats mutate, via commit, and those do not touch the
279
- * returned object). Used by resolveTasks to default {systemPrompt, model,
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
- return agentPool.get(sessionId)?.config;
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, which is
51
- * strictly worse than reporting a cancelled task whose session may still be
52
- * active. On expiry the barrier logs and returns `"abandoned"`.
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 regains ownership of a session
95
- * that may still be active.
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) => setTimeout(resolve, ms));
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;