@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.12

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3464 -3306
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +0 -6
  10. package/dist/types/config/models-config.d.ts +0 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/extensibility/skills.d.ts +29 -0
  15. package/dist/types/lsp/client.d.ts +9 -3
  16. package/dist/types/modes/components/todo-reminder.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +0 -1
  18. package/dist/types/modes/skill-command.d.ts +1 -1
  19. package/dist/types/modes/types.d.ts +0 -1
  20. package/dist/types/stt/asr-client.d.ts +7 -3
  21. package/dist/types/task/spawn-policy.d.ts +17 -0
  22. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  23. package/dist/types/task/types.d.ts +8 -2
  24. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  25. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  26. package/dist/types/tools/browser/registry.d.ts +2 -0
  27. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  28. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  29. package/dist/types/tools/eval.d.ts +3 -6
  30. package/dist/types/tools/write.d.ts +1 -1
  31. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  32. package/package.json +14 -13
  33. package/scripts/bundle-dist.ts +23 -4
  34. package/scripts/generate-docs-index.ts +116 -24
  35. package/src/advisor/__tests__/advisor.test.ts +1 -1
  36. package/src/async/job-manager.ts +27 -3
  37. package/src/auto-thinking/classifier.ts +1 -1
  38. package/src/cli/bench-cli.ts +3 -12
  39. package/src/cli/dry-balance-cli.ts +1 -6
  40. package/src/cli/grep-cli.ts +1 -1
  41. package/src/cli/models-cli.ts +3 -81
  42. package/src/commands/models.ts +1 -2
  43. package/src/commit/model-selection.ts +4 -4
  44. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  45. package/src/config/model-discovery.ts +150 -82
  46. package/src/config/model-registry.ts +11 -225
  47. package/src/config/model-resolver.ts +23 -135
  48. package/src/config/models-config-schema.ts +0 -22
  49. package/src/config/prompt-templates.ts +20 -0
  50. package/src/config/settings-schema.ts +35 -1
  51. package/src/config/settings.ts +60 -24
  52. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  53. package/src/eval/agent-bridge.ts +7 -9
  54. package/src/eval/completion-bridge.ts +1 -1
  55. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  56. package/src/eval/py/runner.py +169 -18
  57. package/src/extensibility/extensions/model-api.ts +1 -3
  58. package/src/extensibility/extensions/runner.ts +62 -5
  59. package/src/extensibility/skills.ts +77 -0
  60. package/src/internal-urls/docs-index.generated.txt +2 -2
  61. package/src/lsp/client.ts +162 -45
  62. package/src/lsp/config.ts +17 -3
  63. package/src/lsp/index.ts +31 -21
  64. package/src/main.ts +0 -1
  65. package/src/mcp/transports/http.ts +19 -17
  66. package/src/mcp/transports/stdio.test.ts +51 -1
  67. package/src/mcp/transports/stdio.ts +19 -9
  68. package/src/memories/index.ts +0 -1
  69. package/src/mnemopi/backend.ts +1 -1
  70. package/src/modes/acp/acp-agent.ts +6 -9
  71. package/src/modes/components/model-selector.ts +32 -186
  72. package/src/modes/components/todo-reminder.ts +5 -1
  73. package/src/modes/controllers/event-controller.ts +1 -16
  74. package/src/modes/controllers/selector-controller.ts +57 -36
  75. package/src/modes/interactive-mode.ts +0 -6
  76. package/src/modes/rpc/rpc-mode.ts +5 -8
  77. package/src/modes/skill-command.ts +8 -20
  78. package/src/modes/types.ts +0 -1
  79. package/src/prompts/system/subagent-system-prompt.md +2 -2
  80. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  81. package/src/prompts/tools/eval.md +2 -2
  82. package/src/prompts/tools/task.md +1 -1
  83. package/src/sdk.ts +2 -9
  84. package/src/session/agent-session.ts +89 -8
  85. package/src/session/session-context.ts +2 -1
  86. package/src/session/session-manager.ts +2 -1
  87. package/src/session/unexpected-stop-classifier.ts +1 -1
  88. package/src/stt/asr-client.ts +87 -27
  89. package/src/stt/downloader.ts +8 -2
  90. package/src/task/executor.ts +1 -3
  91. package/src/task/index.ts +37 -32
  92. package/src/task/spawn-policy.test.ts +63 -0
  93. package/src/task/spawn-policy.ts +58 -0
  94. package/src/task/types.ts +77 -6
  95. package/src/tools/__tests__/eval-description.test.ts +19 -0
  96. package/src/tools/ast-grep.ts +34 -12
  97. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  98. package/src/tools/browser/registry.ts +25 -0
  99. package/src/tools/browser/run-cancellation.ts +47 -0
  100. package/src/tools/browser/tab-supervisor.ts +120 -7
  101. package/src/tools/browser/tab-worker.ts +22 -10
  102. package/src/tools/browser.ts +1 -0
  103. package/src/tools/eval.ts +15 -10
  104. package/src/tools/grep.ts +11 -8
  105. package/src/tools/inspect-image.ts +1 -1
  106. package/src/tools/write.ts +49 -9
  107. package/src/utils/commit-message-generator.ts +0 -1
  108. package/src/utils/git.ts +22 -1
  109. package/src/utils/image-vision-fallback.ts +2 -3
  110. package/src/utils/title-generator.ts +1 -1
@@ -4,12 +4,12 @@ import {
4
4
  createWorkerHandle,
5
5
  createWorkerSubprocess,
6
6
  logWorkerMessage,
7
+ type RefCountedWorkerHandle,
7
8
  resolveWorkerSpawnCmd,
8
9
  SMOKE_TEST_TIMEOUT_MS,
9
10
  type SpawnedSubprocess,
10
11
  smokeTestWorker,
11
12
  spawnWorkerOrUnavailable,
12
- type WorkerHandle,
13
13
  } from "../subprocess/worker-client";
14
14
  import { tinyWorkerEnv } from "../tiny/title-client";
15
15
  import { safeSend } from "../utils/ipc";
@@ -18,7 +18,7 @@ import type { SttModelKey } from "./models";
18
18
 
19
19
  type PendingRequest =
20
20
  | { kind: "transcribe"; modelKey: SttModelKey; resolve: (text: string) => void; reject: (error: Error) => void }
21
- | { kind: "download"; modelKey: SttModelKey; resolve: (ok: boolean) => void };
21
+ | { kind: "download"; modelKey: SttModelKey; resolve: (result: SttDownloadResult) => void };
22
22
 
23
23
  export interface SttTranscribeOptions {
24
24
  language?: string;
@@ -30,6 +30,11 @@ export interface SttDownloadOptions {
30
30
  onProgress?: (event: SttProgressEvent) => void;
31
31
  }
32
32
 
33
+ export interface SttDownloadResult {
34
+ ok: boolean;
35
+ error?: string;
36
+ }
37
+
33
38
  /** Live streaming session handle returned by {@link SttClient.startStream}. */
34
39
  export interface SttStreamHandle {
35
40
  /** Feed 16 kHz mono float samples as the recorder produces them. */
@@ -79,30 +84,55 @@ export function createSttSubprocess(): SpawnedSubprocess<SttWorkerOutbound> {
79
84
 
80
85
  function wrapSubprocess(
81
86
  spawned: SpawnedSubprocess<SttWorkerOutbound>,
82
- ): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
87
+ ): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
83
88
  const { proc } = spawned;
84
- return createWorkerHandle<SttWorkerInbound, SttWorkerOutbound>(spawned, message => safeSend(proc, message, "stt"));
89
+ return {
90
+ ...createWorkerHandle<SttWorkerInbound, SttWorkerOutbound>(spawned, message => safeSend(proc, message, "stt")),
91
+ ref() {
92
+ try {
93
+ proc.ref();
94
+ } catch {
95
+ // Already gone.
96
+ }
97
+ },
98
+ unref() {
99
+ try {
100
+ proc.unref();
101
+ } catch {
102
+ // Already gone.
103
+ }
104
+ },
105
+ };
106
+ }
107
+
108
+ function spawnInlineUnavailableWorker(error: unknown): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
109
+ return {
110
+ ...createUnavailableWorker<SttWorkerInbound, SttWorkerOutbound>(error),
111
+ ref() {},
112
+ unref() {},
113
+ };
85
114
  }
86
115
 
87
- function spawnSttWorker(): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
116
+ function spawnSttWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
88
117
  return spawnWorkerOrUnavailable(
89
118
  () => wrapSubprocess(createSttSubprocess()),
90
- createUnavailableWorker<SttWorkerInbound, SttWorkerOutbound>,
119
+ spawnInlineUnavailableWorker,
91
120
  "stt worker spawn failed; speech-to-text disabled",
92
121
  );
93
122
  }
94
123
 
95
124
  export class SttClient {
96
- #worker: WorkerHandle<SttWorkerInbound, SttWorkerOutbound> | null = null;
125
+ #worker: RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> | null = null;
97
126
  #unsubscribeMessage: (() => void) | null = null;
98
127
  #unsubscribeError: (() => void) | null = null;
99
128
  #pending = new Map<string, PendingRequest>();
100
129
  #streams = new Map<string, StreamState>();
101
130
  #progressListeners = new Set<(event: SttProgressEvent) => void>();
102
131
  #nextRequestId = 0;
103
- #spawnWorker: () => WorkerHandle<SttWorkerInbound, SttWorkerOutbound>;
132
+ #refed = false;
133
+ #spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound>;
104
134
 
105
- constructor(spawnWorker: () => WorkerHandle<SttWorkerInbound, SttWorkerOutbound> = spawnSttWorker) {
135
+ constructor(spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> = spawnSttWorker) {
106
136
  this.#spawnWorker = spawnWorker;
107
137
  }
108
138
 
@@ -121,11 +151,11 @@ export class SttClient {
121
151
  const worker = this.#ensureWorker();
122
152
  const id = String(++this.#nextRequestId);
123
153
  const { promise, resolve, reject } = Promise.withResolvers<string>();
124
- this.#pending.set(id, { kind: "transcribe", modelKey, resolve, reject });
154
+ this.#addPending(id, { kind: "transcribe", modelKey, resolve, reject });
125
155
  const abort = (): void => {
126
156
  const pending = this.#pending.get(id);
127
157
  if (pending?.kind !== "transcribe") return;
128
- this.#pending.delete(id);
158
+ this.#deletePending(id);
129
159
  pending.reject(new DOMException("The operation was aborted.", "AbortError"));
130
160
  };
131
161
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -134,7 +164,7 @@ export class SttClient {
134
164
  return await promise;
135
165
  } finally {
136
166
  options.signal?.removeEventListener("abort", abort);
137
- this.#pending.delete(id);
167
+ this.#deletePending(id);
138
168
  }
139
169
  }
140
170
 
@@ -163,6 +193,7 @@ export class SttClient {
163
193
  settled = true;
164
194
  this.#streams.delete(id);
165
195
  signal?.removeEventListener("abort", onAbort);
196
+ this.#syncWorkerRef();
166
197
  apply();
167
198
  };
168
199
  this.#streams.set(id, {
@@ -173,6 +204,7 @@ export class SttClient {
173
204
  reject,
174
205
  finish,
175
206
  });
207
+ this.#syncWorkerRef();
176
208
  worker.send({ type: "stream_start", id, modelKey, language: options.language });
177
209
  const handle: SttStreamHandle = {
178
210
  pushAudio: audio => {
@@ -193,19 +225,19 @@ export class SttClient {
193
225
  return handle;
194
226
  }
195
227
 
196
- async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<boolean> {
197
- if (options.signal?.aborted) return false;
228
+ async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<SttDownloadResult> {
229
+ if (options.signal?.aborted) return { ok: false };
198
230
  const unsubscribe = options.onProgress ? this.onProgress(options.onProgress) : undefined;
199
231
  try {
200
232
  const worker = this.#ensureWorker();
201
233
  const id = String(++this.#nextRequestId);
202
- const { promise, resolve } = Promise.withResolvers<boolean>();
203
- this.#pending.set(id, { kind: "download", modelKey, resolve });
234
+ const { promise, resolve } = Promise.withResolvers<SttDownloadResult>();
235
+ this.#addPending(id, { kind: "download", modelKey, resolve });
204
236
  const abort = (): void => {
205
237
  const pending = this.#pending.get(id);
206
238
  if (pending?.kind !== "download") return;
207
- this.#pending.delete(id);
208
- pending.resolve(false);
239
+ this.#deletePending(id);
240
+ pending.resolve({ ok: false });
209
241
  };
210
242
  options.signal?.addEventListener("abort", abort, { once: true });
211
243
  try {
@@ -213,14 +245,15 @@ export class SttClient {
213
245
  return await promise;
214
246
  } finally {
215
247
  options.signal?.removeEventListener("abort", abort);
216
- this.#pending.delete(id);
248
+ this.#deletePending(id);
217
249
  }
218
250
  } catch (error) {
251
+ const message = error instanceof Error ? error.message : String(error);
219
252
  logger.debug("stt: local model download failed", {
220
253
  modelKey,
221
- error: error instanceof Error ? error.message : String(error),
254
+ error: message,
222
255
  });
223
- return false;
256
+ return { ok: false, error: message };
224
257
  } finally {
225
258
  unsubscribe?.();
226
259
  }
@@ -236,9 +269,10 @@ export class SttClient {
236
269
  for (const pending of this.#pending.values()) {
237
270
  this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
238
271
  if (pending.kind === "transcribe") pending.reject(new Error("stt worker terminated"));
239
- else pending.resolve(false);
272
+ else pending.resolve({ ok: false });
240
273
  }
241
274
  this.#pending.clear();
275
+ this.#refed = false;
242
276
  this.#failStreams(new Error("stt worker terminated"));
243
277
  try {
244
278
  await worker?.terminate();
@@ -247,7 +281,7 @@ export class SttClient {
247
281
  }
248
282
  }
249
283
 
250
- #ensureWorker(): WorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
284
+ #ensureWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
251
285
  if (this.#worker) return this.#worker;
252
286
  const worker = this.#spawnWorker();
253
287
  this.#worker = worker;
@@ -256,6 +290,32 @@ export class SttClient {
256
290
  return worker;
257
291
  }
258
292
 
293
+ /** Register a pending request and keep the worker referenced while work is in flight. */
294
+ #addPending(id: string, request: PendingRequest): void {
295
+ this.#pending.set(id, request);
296
+ this.#syncWorkerRef();
297
+ }
298
+
299
+ /** Drop a pending request and unref the worker once no request or stream is active. */
300
+ #deletePending(id: string): void {
301
+ if (this.#pending.delete(id)) this.#syncWorkerRef();
302
+ }
303
+
304
+ /**
305
+ * STT workers start unreferenced so an idle warm model never blocks exit.
306
+ * Setup/download commands must keep the worker alive while awaiting IPC, or
307
+ * Bun can drain the event loop immediately after `Preparing Speech-to-Text`.
308
+ */
309
+ #syncWorkerRef(): void {
310
+ const worker = this.#worker;
311
+ if (!worker) return;
312
+ const shouldRef = this.#pending.size > 0 || this.#streams.size > 0;
313
+ if (shouldRef === this.#refed) return;
314
+ this.#refed = shouldRef;
315
+ if (shouldRef) worker.ref();
316
+ else worker.unref();
317
+ }
318
+
259
319
  #handleMessage(message: SttWorkerOutbound): void {
260
320
  if (message.type === "log") {
261
321
  logWorkerMessage(message);
@@ -287,19 +347,19 @@ export class SttClient {
287
347
  }
288
348
  return;
289
349
  }
290
- this.#pending.delete(message.id);
350
+ this.#deletePending(message.id);
291
351
  if (message.type === "transcription") {
292
352
  if (pending.kind === "transcribe") pending.resolve(message.text);
293
353
  return;
294
354
  }
295
355
  if (message.type === "downloaded") {
296
- if (pending.kind === "download") pending.resolve(true);
356
+ if (pending.kind === "download") pending.resolve({ ok: true });
297
357
  return;
298
358
  }
299
359
  // message.type === "error"
300
360
  this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
301
361
  if (pending.kind === "transcribe") pending.reject(new Error(message.error));
302
- else pending.resolve(false);
362
+ else pending.resolve({ ok: false, error: message.error });
303
363
  }
304
364
 
305
365
  #emitProgress(event: SttProgressEvent): void {
@@ -318,7 +378,7 @@ export class SttClient {
318
378
  for (const pending of this.#pending.values()) {
319
379
  this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
320
380
  if (pending.kind === "transcribe") pending.reject(error);
321
- else pending.resolve(false);
381
+ else pending.resolve({ ok: false, error: error.message });
322
382
  }
323
383
  this.#pending.clear();
324
384
  this.#failStreams(error);
@@ -90,7 +90,7 @@ export async function downloadSttModel(
90
90
  ): Promise<void> {
91
91
  const spec = resolveSttModelSpec(key);
92
92
  const files = new Map<string, { loaded: number; total: number }>();
93
- const ok = await sttClient.downloadModel(spec.key, {
93
+ const result = await sttClient.downloadModel(spec.key, {
94
94
  signal: options?.signal,
95
95
  onProgress: event => {
96
96
  if ((event.status === "progress" || event.status === "progress_total") && event.file) {
@@ -117,7 +117,13 @@ export async function downloadSttModel(
117
117
  });
118
118
  },
119
119
  });
120
- if (!ok) throw new Error(`Failed to download speech model (${spec.repo}). Check your network connection.`);
120
+ if (!result.ok) {
121
+ const detail = result.error ? `: ${result.error}` : ". Check your network connection.";
122
+ throw new Error(`Failed to download speech model (${spec.repo})${detail}`);
123
+ }
124
+ if (!(await isSttModelCached(spec.key))) {
125
+ throw new Error(`Speech model download finished without required files (${spec.repo}).`);
126
+ }
121
127
  }
122
128
 
123
129
  // ── Public API ─────────────────────────────────────────────────────
@@ -2053,9 +2053,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2053
2053
  ? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
2054
2054
  : formatModelStringWithRouting(model);
2055
2055
  }
2056
- const effectiveThinkingLevel = explicitThinkingLevel
2057
- ? resolvedThinkingLevel
2058
- : (thinkingLevel ?? resolvedThinkingLevel);
2056
+ const effectiveThinkingLevel = thinkingLevel ?? resolvedThinkingLevel;
2059
2057
  resolvedAt = performance.now();
2060
2058
 
2061
2059
  const effectiveCwd = worktree ?? cwd;
package/src/task/index.ts CHANGED
@@ -30,6 +30,7 @@ import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type:
30
30
  import { truncateForPrompt } from "../tools/approval";
31
31
  import { isIrcEnabled } from "../tools/irc";
32
32
  import { formatBytes, formatDuration } from "../tools/render-utils";
33
+ import { DEFAULT_SPAWN_AGENT, resolveSpawnPolicy } from "./spawn-policy";
33
34
  import {
34
35
  type AgentDefinition,
35
36
  type AgentProgress,
@@ -187,17 +188,13 @@ function renderDescription(
187
188
  ircEnabled: boolean,
188
189
  parentSpawns: string,
189
190
  ): string {
190
- const spawningDisabled = parentSpawns === "";
191
+ const spawnPolicy = resolveSpawnPolicy(parentSpawns);
192
+ const spawningDisabled = !spawnPolicy.enabled;
191
193
  let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
192
194
  if (spawningDisabled) {
193
195
  filteredAgents = [];
194
- } else if (parentSpawns !== "*") {
195
- const allowed = new Set(
196
- parentSpawns
197
- .split(",")
198
- .map(s => s.trim())
199
- .filter(Boolean),
200
- );
196
+ } else if (spawnPolicy.allowedAgents !== null) {
197
+ const allowed = new Set(spawnPolicy.allowedAgents);
201
198
  filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
202
199
  }
203
200
  const renderedAgents = filteredAgents.map(agent => ({
@@ -208,6 +205,9 @@ function renderDescription(
208
205
  return prompt.render(taskDescriptionTemplate, {
209
206
  agents: renderedAgents,
210
207
  spawningDisabled,
208
+ defaultAgent: spawnPolicy.defaultAgent,
209
+ defaultAgentIsGeneric: spawnPolicy.defaultAgent === DEFAULT_SPAWN_AGENT,
210
+ allowedAgentsText: spawnPolicy.allowedPromptText,
211
211
  MAX_CONCURRENCY: maxConcurrency,
212
212
  isolationEnabled,
213
213
  batchEnabled,
@@ -331,9 +331,6 @@ function spawnParamsFor(params: TaskParams, item: TaskItem): TaskParams {
331
331
  return spawn;
332
332
  }
333
333
 
334
- /** Agent type spawned when a `task` call omits `agent`; mirrors the schema default in `getTaskSchema`. */
335
- const DEFAULT_TASK_AGENT = "task";
336
-
337
334
  /** Generic worker agents whose output sharpens with a tailored `role` rather than the bare type. */
338
335
  const GENERIC_SPAWN_AGENTS: ReadonlySet<string> = new Set(["task", "sonic"]);
339
336
 
@@ -511,7 +508,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
511
508
 
512
509
  get parameters(): TaskToolSchemaInstance {
513
510
  const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
514
- return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled() });
511
+ const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
512
+ return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled(), defaultAgent });
515
513
  }
516
514
 
517
515
  renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
@@ -566,13 +564,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
566
564
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
567
565
  ): Promise<AgentToolResult<TaskToolDetails>> {
568
566
  const repaired = repairTaskParams(rawParams as TaskParams);
569
- // The schema defaults `agent` to `task` for model calls, but internal
570
- // callers and stale transcripts build params directly and bypass arktype.
571
- // Normalize once here so every downstream path sees the resolved agent.
567
+ // Schema defaults run for model calls, but internal callers and stale
568
+ // transcripts can bypass arktype. Normalize once so every downstream path
569
+ // sees the session's actual default agent.
570
+ const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
572
571
  const params =
573
572
  typeof repaired.agent === "string" && repaired.agent.trim() !== ""
574
573
  ? repaired
575
- : { ...repaired, agent: DEFAULT_TASK_AGENT };
574
+ : { ...repaired, agent: defaultAgent };
576
575
  const batchEnabled = this.#isBatchEnabled();
577
576
  const validationError = validateShapeParams(batchEnabled, params) ?? validateSpawnParams(params, batchEnabled);
578
577
  if (validationError) {
@@ -803,10 +802,19 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
803
802
  async ({ jobId: ownJobId, signal: runSignal, reportProgress, markRunning }) => {
804
803
  const startedAt = Date.now();
805
804
  const semaphore = this.#getSpawnSemaphore();
806
- await semaphore.acquire();
805
+ let semaphoreHeld = false;
806
+ try {
807
+ await semaphore.acquire(runSignal);
808
+ semaphoreHeld = true;
809
+ } catch {
810
+ // Fall through so an acquire-time abort goes through the same
811
+ // path as the post-acquire race below: progress + onSettled
812
+ // have to fire even when the spawn never reached the executor,
813
+ // otherwise the batch aggregate state stays "running" forever.
814
+ }
807
815
  const acquiredAt = Date.now();
808
- if (runSignal.aborted) {
809
- semaphore.release();
816
+ if (!semaphoreHeld || runSignal.aborted) {
817
+ if (semaphoreHeld) semaphore.release();
810
818
  progress.status = "aborted";
811
819
  onSettled?.(true);
812
820
  throw new Error("Aborted before execution");
@@ -909,7 +917,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
909
917
  const semaphore = this.#getSpawnSemaphore();
910
918
  if (spawnItems.length === 1) {
911
919
  const invokedAt = Date.now();
912
- await semaphore.acquire();
920
+ await semaphore.acquire(signal);
913
921
  const acquiredAt = Date.now();
914
922
  try {
915
923
  return await this.#executeSync(
@@ -948,7 +956,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
948
956
  spawnItems.length,
949
957
  async (item, index, workerSignal) => {
950
958
  const invokedAt = Date.now();
951
- await semaphore.acquire();
959
+ await semaphore.acquire(workerSignal);
952
960
  const acquiredAt = Date.now();
953
961
  try {
954
962
  const itemOnUpdate: AgentToolUpdateCallback<TaskToolDetails> | undefined = onUpdate
@@ -1180,18 +1188,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1180
1188
  }
1181
1189
 
1182
1190
  // Check spawn restrictions from parent
1183
- const parentSpawns = this.session.getSessionSpawns() ?? "*";
1184
- const allowedSpawns = parentSpawns.split(",").map(s => s.trim());
1185
- const isSpawnAllowed = (): boolean => {
1186
- if (parentSpawns === "") return false; // Empty = deny all
1187
- if (parentSpawns === "*") return true; // Wildcard = allow all
1188
- return allowedSpawns.includes(agentName);
1189
- };
1190
-
1191
- if (!isSpawnAllowed()) {
1192
- const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
1191
+ const spawnPolicy = resolveSpawnPolicy(this.session.getSessionSpawns());
1192
+ const spawnAllowed =
1193
+ spawnPolicy.enabled &&
1194
+ (spawnPolicy.allowedAgents === null || spawnPolicy.allowedAgents.includes(agentName));
1195
+ if (!spawnAllowed) {
1193
1196
  return {
1194
- content: [{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${allowed}` }],
1197
+ content: [
1198
+ { type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}` },
1199
+ ],
1195
1200
  details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
1196
1201
  };
1197
1202
  }
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
+ import { Settings } from "../config/settings";
3
+ import type { ToolSession } from "../tools";
4
+ import * as taskDiscovery from "./discovery";
5
+ import { TaskTool } from "./index";
6
+ import type { AgentDefinition } from "./types";
7
+ import { getTaskSchema } from "./types";
8
+
9
+ const factFinderAgent = {
10
+ name: "fact-finder",
11
+ description: "Find facts.",
12
+ systemPrompt: "Find facts.",
13
+ source: "project",
14
+ } satisfies AgentDefinition;
15
+
16
+ const oracleAgent = {
17
+ name: "oracle",
18
+ description: "Answer hard questions.",
19
+ systemPrompt: "Answer hard questions.",
20
+ source: "bundled",
21
+ } satisfies AgentDefinition;
22
+
23
+ function makeSession(spawns: string): ToolSession {
24
+ const settings = Settings.isolated({
25
+ "async.enabled": false,
26
+ "task.batch": true,
27
+ "task.isolation.mode": "none",
28
+ });
29
+ return {
30
+ cwd: process.cwd(),
31
+ hasUI: false,
32
+ settings,
33
+ getSessionFile: () => null,
34
+ getSessionSpawns: () => spawns,
35
+ };
36
+ }
37
+
38
+ describe("task spawn policy surfaces", () => {
39
+ afterEach(() => {
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ it("uses the first allowed spawn as the schema default", () => {
44
+ const schema = getTaskSchema({ isolationEnabled: false, batchEnabled: false, defaultAgent: "fact-finder" });
45
+ const parsed = schema({ assignment: "check" });
46
+
47
+ expect(parsed).toEqual({ agent: "fact-finder", assignment: "check" });
48
+ });
49
+
50
+ it("renders the restricted spawn default in the task description", async () => {
51
+ vi.spyOn(taskDiscovery, "discoverAgents").mockResolvedValue({
52
+ agents: [factFinderAgent, oracleAgent],
53
+ projectAgentsDir: null,
54
+ });
55
+
56
+ const tool = await TaskTool.create(makeSession("fact-finder,oracle"));
57
+ const description = tool.description;
58
+
59
+ expect(description).toContain("Defaults to `fact-finder`");
60
+ expect(description).toContain("Current spawn policy allows: `fact-finder`, `oracle`.");
61
+ expect(description).not.toContain("Defaults to `task`");
62
+ });
63
+ });
@@ -0,0 +1,58 @@
1
+ /** Default agent used when a session has unrestricted spawning. */
2
+ export const DEFAULT_SPAWN_AGENT = "task";
3
+
4
+ /** Spawn policy derived from a parent agent's `spawns` frontmatter. */
5
+ export interface ResolvedSpawnPolicy {
6
+ /** True when at least one subagent may be spawned. */
7
+ enabled: boolean;
8
+ /** Agent used when the caller omits the agent field. */
9
+ defaultAgent: string;
10
+ /** Explicitly allowed agents, or `null` when the policy is unrestricted. */
11
+ allowedAgents: readonly string[] | null;
12
+ /** Text used in spawn rejection messages. */
13
+ allowedErrorText: string;
14
+ /** Backtick-quoted explicit agents for prompt descriptions. */
15
+ allowedPromptText?: string;
16
+ }
17
+
18
+ /** Resolves spawn frontmatter into the default and prompt/error surfaces. */
19
+ export function resolveSpawnPolicy(parentSpawns: string | boolean | null | undefined): ResolvedSpawnPolicy {
20
+ let normalized: string;
21
+ if (parentSpawns === false) {
22
+ normalized = "";
23
+ } else if (parentSpawns === true || parentSpawns === null || parentSpawns === undefined) {
24
+ normalized = "*";
25
+ } else {
26
+ normalized = parentSpawns.trim();
27
+ }
28
+
29
+ if (normalized === "*") {
30
+ return {
31
+ enabled: true,
32
+ defaultAgent: DEFAULT_SPAWN_AGENT,
33
+ allowedAgents: null,
34
+ allowedErrorText: "*",
35
+ };
36
+ }
37
+
38
+ const allowedAgents = normalized
39
+ .split(",")
40
+ .map(spawn => spawn.trim())
41
+ .filter(Boolean);
42
+ if (allowedAgents.length === 0) {
43
+ return {
44
+ enabled: false,
45
+ defaultAgent: DEFAULT_SPAWN_AGENT,
46
+ allowedAgents,
47
+ allowedErrorText: "none (spawns disabled for this agent)",
48
+ };
49
+ }
50
+
51
+ return {
52
+ enabled: true,
53
+ defaultAgent: allowedAgents[0] ?? DEFAULT_SPAWN_AGENT,
54
+ allowedAgents,
55
+ allowedErrorText: allowedAgents.join(","),
56
+ allowedPromptText: allowedAgents.map(agent => `\`${agent}\``).join(", "),
57
+ };
58
+ }
package/src/task/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Usage } from "@oh-my-pi/pi-ai";
3
3
  import { $env } from "@oh-my-pi/pi-utils";
4
- import { type } from "arktype";
4
+ import { type BaseType, type } from "arktype";
5
5
  import type { AgentSessionEvent } from "../session/agent-session";
6
6
  import type { NestedRepoPatch } from "./worktree";
7
7
 
@@ -144,13 +144,84 @@ const ALL_TASK_SCHEMAS = [taskSchema, taskSchemaNoIsolation, taskSchemaBatch, ta
144
144
  type DynamicTaskSchema = (typeof ALL_TASK_SCHEMAS)[number];
145
145
  export type TaskSchema = typeof taskSchema;
146
146
  /** Active task tool parameter schema for the current isolation / batch flags */
147
- export type TaskToolSchemaInstance = DynamicTaskSchema;
147
+ export type TaskToolSchemaInstance = DynamicTaskSchema | BaseType;
148
148
 
149
- export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema {
149
+ const TASK_AGENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
150
+ const taskSchemaCache = new Map<string, BaseType>();
151
+
152
+ function taskAgentSchemaRule(defaultAgent: string): string {
153
+ const trimmed = defaultAgent.trim();
154
+ if (TASK_AGENT_NAME_PATTERN.test(trimmed)) {
155
+ return `string = '${trimmed}'`;
156
+ }
157
+ return "string";
158
+ }
159
+
160
+ function createTaskSchema(options: {
161
+ isolationEnabled: boolean;
162
+ batchEnabled: boolean;
163
+ defaultAgent: string;
164
+ }): BaseType {
165
+ const agent = taskAgentSchemaRule(options.defaultAgent);
150
166
  if (options.batchEnabled) {
151
- return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
167
+ if (options.isolationEnabled) {
168
+ return type.raw({
169
+ agent,
170
+ context: "string",
171
+ tasks: taskItemSchemaIsolated.array(),
172
+ "+": "delete",
173
+ });
174
+ }
175
+ return type.raw({
176
+ agent,
177
+ context: "string",
178
+ tasks: taskItemSchema.array(),
179
+ "+": "delete",
180
+ });
181
+ }
182
+ if (options.isolationEnabled) {
183
+ return type.raw({
184
+ agent,
185
+ "id?": "string",
186
+ "description?": "string",
187
+ "role?": ROLE_INPUT_SCHEMA,
188
+ assignment: "string",
189
+ "isolated?": "boolean",
190
+ "+": "delete",
191
+ });
192
+ }
193
+ return type.raw({
194
+ agent,
195
+ "id?": "string",
196
+ "description?": "string",
197
+ "role?": ROLE_INPUT_SCHEMA,
198
+ assignment: "string",
199
+ "+": "delete",
200
+ });
201
+ }
202
+
203
+ export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema;
204
+ export function getTaskSchema(options: {
205
+ isolationEnabled: boolean;
206
+ batchEnabled: boolean;
207
+ defaultAgent: string;
208
+ }): TaskToolSchemaInstance;
209
+ export function getTaskSchema(options: {
210
+ isolationEnabled: boolean;
211
+ batchEnabled: boolean;
212
+ defaultAgent?: string;
213
+ }): TaskToolSchemaInstance {
214
+ const defaultAgent = options.defaultAgent ?? "task";
215
+ if (defaultAgent === "task") {
216
+ if (options.batchEnabled) return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
217
+ return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
152
218
  }
153
- return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
219
+ const key = `${options.isolationEnabled ? "iso" : "flat"}:${options.batchEnabled ? "batch" : "single"}:${defaultAgent}`;
220
+ const cached = taskSchemaCache.get(key);
221
+ if (cached) return cached;
222
+ const schema = createTaskSchema({ ...options, defaultAgent });
223
+ taskSchemaCache.set(key, schema);
224
+ return schema;
154
225
  }
155
226
 
156
227
  /**
@@ -160,7 +231,7 @@ export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled
160
231
  * transcripts using the flat form keep working under either setting.
161
232
  */
162
233
  export interface TaskParams {
163
- /** Agent type to spawn; defaults to `"task"` (the general-purpose worker) when omitted. */
234
+ /** Agent type to spawn; omitted values resolve from the session spawn policy. */
164
235
  agent?: string;
165
236
  /** Stable agent id (flat form); default = generated AdjectiveNoun. */
166
237
  id?: string;