@oh-my-pi/pi-coding-agent 17.0.8 → 17.0.9

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 (90) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/cli.js +4850 -4774
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +27 -0
  5. package/dist/types/config/settings-schema.d.ts +34 -3
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  7. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  9. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  10. package/dist/types/lsp/client.d.ts +2 -0
  11. package/dist/types/mcp/manager.d.ts +6 -2
  12. package/dist/types/mcp/render.d.ts +2 -2
  13. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  14. package/dist/types/mcp/types.d.ts +6 -0
  15. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  16. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  17. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  18. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  19. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  20. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  21. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  22. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  23. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  24. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +8 -1
  26. package/dist/types/session/session-loader.d.ts +6 -4
  27. package/dist/types/task/types.d.ts +14 -0
  28. package/dist/types/tools/hub/jobs.d.ts +1 -1
  29. package/dist/types/tools/index.d.ts +3 -0
  30. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  31. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  32. package/dist/types/web/search/types.d.ts +1 -1
  33. package/package.json +13 -12
  34. package/scripts/legacy-pi-virtual-module.ts +1 -1
  35. package/src/cli/grievances-cli.ts +2 -2
  36. package/src/cli/usage-cli.ts +1 -1
  37. package/src/config/__tests__/model-registry.test.ts +147 -0
  38. package/src/config/model-registry.ts +40 -0
  39. package/src/config/model-resolver.ts +0 -1
  40. package/src/config/settings-schema.ts +41 -3
  41. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  42. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  43. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  44. package/src/internal-urls/registry-helpers.ts +40 -0
  45. package/src/lsp/client.ts +23 -0
  46. package/src/lsp/clients/biome-client.ts +3 -4
  47. package/src/lsp/index.ts +25 -7
  48. package/src/main.ts +1 -0
  49. package/src/mcp/manager.ts +39 -8
  50. package/src/mcp/render.ts +94 -35
  51. package/src/mcp/tool-bridge.ts +107 -11
  52. package/src/mcp/types.ts +7 -0
  53. package/src/modes/components/agent-hub.ts +26 -9
  54. package/src/modes/components/oauth-selector.ts +24 -7
  55. package/src/modes/components/settings-defs.ts +5 -2
  56. package/src/modes/components/settings-selector.ts +9 -5
  57. package/src/modes/components/tool-execution.test.ts +63 -2
  58. package/src/modes/controllers/command-controller.ts +14 -7
  59. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  60. package/src/modes/interactive-mode.ts +3 -0
  61. package/src/modes/rpc/rpc-client.ts +94 -3
  62. package/src/modes/rpc/rpc-frame.ts +164 -4
  63. package/src/modes/rpc/rpc-messages.ts +127 -0
  64. package/src/modes/rpc/rpc-mode.ts +79 -7
  65. package/src/modes/rpc/rpc-types.ts +34 -2
  66. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  67. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  68. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  69. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  70. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  71. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  72. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  73. package/src/prompts/goals/guided-goal-system.md +24 -3
  74. package/src/prompts/tools/task.md +12 -2
  75. package/src/sdk.ts +45 -3
  76. package/src/session/agent-session.ts +63 -32
  77. package/src/session/session-context.test.ts +224 -1
  78. package/src/session/session-context.ts +41 -2
  79. package/src/session/session-loader.ts +10 -5
  80. package/src/slash-commands/helpers/usage-report.ts +3 -1
  81. package/src/task/executor.ts +3 -0
  82. package/src/task/index.ts +52 -8
  83. package/src/task/structured-subagent.ts +3 -1
  84. package/src/task/types.ts +16 -0
  85. package/src/tools/hub/index.ts +1 -1
  86. package/src/tools/hub/jobs.ts +67 -8
  87. package/src/tools/index.ts +3 -0
  88. package/src/tools/report-tool-issue.ts +79 -28
  89. package/src/web/search/providers/firecrawl.ts +46 -13
  90. package/src/web/search/types.ts +5 -1
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
4
  import * as snapcompact from "@oh-my-pi/snapcompact";
4
- import type { CompactionSummaryMessage } from "./messages";
5
+ import { type CompactionSummaryMessage, INTERRUPTED_THINKING_MESSAGE_TYPE } from "./messages";
5
6
  import { buildSessionContext, type StrippedToolCallsMarker } from "./session-context";
6
7
  import type { SessionEntry } from "./session-entries";
7
8
 
@@ -159,3 +160,225 @@ describe("buildSessionContext dangling toolCalls", () => {
159
160
  expect(context.messages.some(message => message.role === "assistant")).toBe(false);
160
161
  });
161
162
  });
163
+
164
+ const assistantUsage: AssistantMessage["usage"] = {
165
+ input: 0,
166
+ output: 0,
167
+ cacheRead: 0,
168
+ cacheWrite: 0,
169
+ totalTokens: 0,
170
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
171
+ };
172
+
173
+ function userEntry(id: string, parentId: string | null, content: string, messageTimestamp: number): SessionEntry {
174
+ return {
175
+ type: "message",
176
+ id,
177
+ parentId,
178
+ timestamp,
179
+ message: { role: "user", content, timestamp: messageTimestamp } as AgentMessage,
180
+ };
181
+ }
182
+
183
+ function assistantEntry(
184
+ id: string,
185
+ parentId: string | null,
186
+ stopReason: AssistantMessage["stopReason"],
187
+ text: string,
188
+ messageTimestamp: number,
189
+ ): SessionEntry {
190
+ return {
191
+ type: "message",
192
+ id,
193
+ parentId,
194
+ timestamp,
195
+ message: {
196
+ role: "assistant",
197
+ content: [{ type: "text", text }],
198
+ api: "anthropic-messages",
199
+ provider: "anthropic",
200
+ model: "claude-sonnet-4-5",
201
+ usage: assistantUsage,
202
+ stopReason,
203
+ timestamp: messageTimestamp,
204
+ } satisfies AssistantMessage,
205
+ };
206
+ }
207
+
208
+ function toolCallAssistantEntry(
209
+ id: string,
210
+ parentId: string | null,
211
+ stopReason: AssistantMessage["stopReason"],
212
+ toolCallId: string,
213
+ messageTimestamp: number,
214
+ ): SessionEntry {
215
+ return {
216
+ type: "message",
217
+ id,
218
+ parentId,
219
+ timestamp,
220
+ message: {
221
+ role: "assistant",
222
+ content: [{ type: "toolCall", id: toolCallId, name: "write", arguments: { path: "plan.md", content: "x" } }],
223
+ api: "anthropic-messages",
224
+ provider: "anthropic",
225
+ model: "claude-sonnet-4-5",
226
+ usage: assistantUsage,
227
+ stopReason,
228
+ timestamp: messageTimestamp,
229
+ } satisfies AssistantMessage,
230
+ };
231
+ }
232
+
233
+ function syntheticToolResultEntry(
234
+ id: string,
235
+ parentId: string | null,
236
+ toolCallId: string,
237
+ messageTimestamp: number,
238
+ ): SessionEntry {
239
+ return {
240
+ type: "message",
241
+ id,
242
+ parentId,
243
+ timestamp,
244
+ message: {
245
+ role: "toolResult",
246
+ toolCallId,
247
+ toolName: "write",
248
+ content: [
249
+ { type: "text", text: "Tool call was not executed because the provider stream ended with an error." },
250
+ ],
251
+ details: { __synthetic: true, source: "assistant_stop_error", executed: false },
252
+ isError: true,
253
+ timestamp: messageTimestamp,
254
+ } as AgentMessage,
255
+ };
256
+ }
257
+
258
+ function hiddenContinuityEntry(id: string, parentId: string | null): SessionEntry {
259
+ return {
260
+ type: "custom_message",
261
+ id,
262
+ parentId,
263
+ timestamp,
264
+ customType: INTERRUPTED_THINKING_MESSAGE_TYPE,
265
+ content: "preserved interrupted thinking",
266
+ display: false,
267
+ attribution: "agent",
268
+ };
269
+ }
270
+
271
+ function expectUserTail(messages: AgentMessage[], content: string): void {
272
+ const tail = messages.at(-1);
273
+ expect(tail?.role).toBe("user");
274
+ if (tail?.role !== "user") {
275
+ throw new Error(`Expected user tail, received ${tail?.role ?? "none"}`);
276
+ }
277
+ expect(tail.content).toBe(content);
278
+ }
279
+
280
+ describe("buildSessionContext failed replay tails", () => {
281
+ it("terminates on cyclic parent links and includes each reachable message once", () => {
282
+ const entries = [userEntry("A", "B", "from A", 1), userEntry("B", "A", "from B", 2)];
283
+
284
+ const context = buildSessionContext(entries, "A");
285
+
286
+ expect(context.messages.map(message => (message.role === "user" ? message.content : message.role))).toEqual([
287
+ "from B",
288
+ "from A",
289
+ ]);
290
+ });
291
+
292
+ it("omits a terminal aborted assistant from normal context", () => {
293
+ const context = buildSessionContext([
294
+ userEntry("user", null, "continue", 1),
295
+ assistantEntry("assistant", "user", "aborted", "partial unsafe replay", 2),
296
+ ]);
297
+
298
+ expect(context.messages.some(message => message.role === "assistant")).toBe(false);
299
+ expectUserTail(context.messages, "continue");
300
+ });
301
+
302
+ it("omits an earlier aborted assistant before a later user from normal context", () => {
303
+ const context = buildSessionContext([
304
+ userEntry("user-1", null, "first prompt", 1),
305
+ assistantEntry("assistant", "user-1", "aborted", "partial unsafe replay", 2),
306
+ userEntry("user-2", "assistant", "retry", 3),
307
+ ]);
308
+
309
+ expect(context.messages.some(message => message.role === "assistant")).toBe(false);
310
+ expectUserTail(context.messages, "retry");
311
+ });
312
+
313
+ it("preserves a terminal aborted assistant in transcript mode", () => {
314
+ const context = buildSessionContext(
315
+ [
316
+ userEntry("user", null, "continue", 1),
317
+ assistantEntry("assistant", "user", "aborted", "visible transcript error", 2),
318
+ ],
319
+ undefined,
320
+ undefined,
321
+ { transcript: true },
322
+ );
323
+
324
+ const assistant = context.messages.find(message => message.role === "assistant");
325
+ expect(assistant?.role).toBe("assistant");
326
+ if (assistant?.role !== "assistant") {
327
+ throw new Error(`Expected transcript assistant, received ${assistant?.role ?? "none"}`);
328
+ }
329
+ expect(assistant.stopReason).toBe("aborted");
330
+ expect(assistant.content).toEqual([{ type: "text", text: "visible transcript error" }]);
331
+ });
332
+
333
+ it("omits a terminal error assistant from normal context", () => {
334
+ const context = buildSessionContext([
335
+ userEntry("user", null, "retry with smaller input", 1),
336
+ assistantEntry("assistant", "user", "error", "provider rejected the request", 2),
337
+ ]);
338
+
339
+ expect(context.messages.some(message => message.role === "assistant")).toBe(false);
340
+ expectUserTail(context.messages, "retry with smaller input");
341
+ });
342
+
343
+ it("keeps an aborted assistant when hidden interrupted-thinking continuity follows it", () => {
344
+ const context = buildSessionContext([
345
+ userEntry("user", null, "keep reasoning continuity", 1),
346
+ assistantEntry("assistant", "user", "aborted", "partial answer before interrupt", 2),
347
+ hiddenContinuityEntry("continuity", "assistant"),
348
+ ]);
349
+
350
+ const assistant = context.messages.find(message => message.role === "assistant");
351
+ expect(assistant?.role).toBe("assistant");
352
+ if (assistant?.role !== "assistant") {
353
+ throw new Error(`Expected assistant before continuity, received ${assistant?.role ?? "none"}`);
354
+ }
355
+ expect(assistant.stopReason).toBe("aborted");
356
+ expect(context.messages.at(-1)?.role).toBe("custom");
357
+ });
358
+
359
+ it("drops synthetic tool results paired with a dropped failed tool-call turn", () => {
360
+ const context = buildSessionContext([
361
+ userEntry("user", null, "write the plan", 1),
362
+ toolCallAssistantEntry("assistant", "user", "error", "call-1", 2),
363
+ syntheticToolResultEntry("result", "assistant", "call-1", 3),
364
+ ]);
365
+
366
+ expect(context.messages.map(message => message.role)).toEqual(["user"]);
367
+ expectUserTail(context.messages, "write the plan");
368
+ });
369
+
370
+ it("keeps the failed tool-call turn and its result in transcript mode", () => {
371
+ const context = buildSessionContext(
372
+ [
373
+ userEntry("user", null, "write the plan", 1),
374
+ toolCallAssistantEntry("assistant", "user", "error", "call-1", 2),
375
+ syntheticToolResultEntry("result", "assistant", "call-1", 3),
376
+ ],
377
+ undefined,
378
+ undefined,
379
+ { transcript: true, keepDanglingToolCalls: true },
380
+ );
381
+
382
+ expect(context.messages.map(message => message.role)).toEqual(["user", "assistant", "toolResult"]);
383
+ });
384
+ });
@@ -5,6 +5,7 @@ import {
5
5
  createBranchSummaryMessage,
6
6
  createCompactionSummaryMessage,
7
7
  createCustomMessage,
8
+ INTERRUPTED_THINKING_MESSAGE_TYPE,
8
9
  isCustomMessageContent,
9
10
  normalizeCustomMessagePayload,
10
11
  } from "./messages";
@@ -197,10 +198,13 @@ export function buildSessionContext(
197
198
  };
198
199
  }
199
200
 
200
- // Walk from leaf to root, collecting path
201
+ // Walk from leaf to root, collecting path. Corrupt/pre-fix files can contain
202
+ // parent cycles; stop at the first repeat so session load is bounded.
201
203
  const path: SessionEntry[] = [];
204
+ const seenPathIds = new Set<string>();
202
205
  let current: SessionEntry | undefined = leaf;
203
- while (current) {
206
+ while (current && !seenPathIds.has(current.id)) {
207
+ seenPathIds.add(current.id);
204
208
  path.push(current);
205
209
  current = current.parentId ? byId.get(current.parentId) : undefined;
206
210
  }
@@ -498,6 +502,41 @@ export function buildSessionContext(
498
502
  }
499
503
  }
500
504
 
505
+ // Error/abort assistant turns are transcript events, not safe assistant
506
+ // turns to replay into the next provider request. Drop them even when a
507
+ // later user message follows through non-context entries (`session_exit`,
508
+ // labels, etc.); otherwise a resumed session replays a dead partial turn
509
+ // and can spend minutes reprocessing old context before the new prompt.
510
+ // Keep the interrupted-thinking continuity pair: convertToLlm strips the
511
+ // unsafe trailing thinking from that assistant and sends the hidden
512
+ // continuity note instead.
513
+ if (!options?.transcript) {
514
+ for (let i = messages.length - 1; i >= 0; i--) {
515
+ const message = messages[i];
516
+ if (message?.role !== "assistant") continue;
517
+ if (message.stopReason !== "aborted" && message.stopReason !== "error") continue;
518
+ const next = messages[i + 1];
519
+ if (next?.role === "custom" && next.customType === INTERRUPTED_THINKING_MESSAGE_TYPE) continue;
520
+ // A failed turn that emitted tool calls persists paired synthetic
521
+ // tool_result placeholders after it. Dropping only the assistant would
522
+ // strand those results with no preceding tool_use — a shape providers
523
+ // reject — so remove the paired results alongside the turn.
524
+ const droppedToolCallIds = new Set<string>();
525
+ for (const block of message.content) {
526
+ if (block.type === "toolCall") droppedToolCallIds.add(block.id);
527
+ }
528
+ messages.splice(i, 1);
529
+ if (droppedToolCallIds.size > 0) {
530
+ for (let j = messages.length - 1; j >= i; j--) {
531
+ const candidate = messages[j];
532
+ if (candidate?.role === "toolResult" && droppedToolCallIds.has(candidate.toolCallId)) {
533
+ messages.splice(j, 1);
534
+ }
535
+ }
536
+ }
537
+ }
538
+ }
539
+
501
540
  return {
502
541
  messages,
503
542
  cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined,
@@ -306,10 +306,12 @@ export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore:
306
306
  }
307
307
 
308
308
  /**
309
- * Read-only message view of a session file: load entries, migrate to the
310
- * current version, resolve blob refs, and build the context along the
311
- * persisted leaf path (last entry). Does NOT create a writer or take the
312
- * session lock safe to call against a file another session is writing.
309
+ * Read-only transcript view of a session file: load entries, migrate to the
310
+ * current version, resolve blob refs, and build the display transcript along
311
+ * the persisted leaf path (last entry). Uses transcript mode (collapsed to the
312
+ * latest compaction) so failed/aborted tail turns stay visible, unlike the
313
+ * provider-context builder which drops them. Does NOT create a writer or take
314
+ * the session lock — safe to call against a file another session is writing.
313
315
  */
314
316
  export async function loadSessionMessagesReadOnly(filePath: string): Promise<AgentMessage[]> {
315
317
  const entries = await loadEntriesFromFile(filePath);
@@ -317,5 +319,8 @@ export async function loadSessionMessagesReadOnly(filePath: string): Promise<Age
317
319
  migrateToCurrentVersion(entries);
318
320
  await resolveBlobRefsInEntries(entries, new BlobStore(getBlobsDir()));
319
321
  const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session");
320
- return buildSessionContext(sessionEntries).messages;
322
+ return buildSessionContext(sessionEntries, undefined, undefined, {
323
+ transcript: true,
324
+ collapseCompactedHistory: true,
325
+ }).messages;
321
326
  }
@@ -124,7 +124,9 @@ function renderUsageReports(
124
124
  );
125
125
  lines.push(` ${renderAsciiBar(limit.amount.usedFraction)}`);
126
126
  if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
127
- lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
127
+ lines.push(
128
+ ` ${limit.window.resetLabel ?? "resets"} in ${formatDuration(limit.window.resetsAt - nowMs)}`,
129
+ );
128
130
  }
129
131
  if (limit.notes && limit.notes.length > 0)
130
132
  lines.push(
@@ -1578,11 +1578,13 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1578
1578
  }
1579
1579
  if (event.type === "retry_fallback_applied") {
1580
1580
  progress.resolvedModel = event.to;
1581
+ progress.resolvedModelIsFallback = true;
1581
1582
  scheduleProgress(true);
1582
1583
  return;
1583
1584
  }
1584
1585
  if (event.type === "retry_fallback_succeeded") {
1585
1586
  progress.resolvedModel = event.model;
1587
+ progress.resolvedModelIsFallback = true;
1586
1588
  scheduleProgress(true);
1587
1589
  return;
1588
1590
  }
@@ -1989,6 +1991,7 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
1989
1991
  contextWindow: progress.contextWindow,
1990
1992
  modelOverride,
1991
1993
  resolvedModel: progress.resolvedModel,
1994
+ resolvedModelIsFallback: progress.resolvedModelIsFallback,
1992
1995
  error: exitCode !== 0 && stderr ? stderr : undefined,
1993
1996
  aborted: wasAborted,
1994
1997
  abortReason: finalAbortReason,
package/src/task/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  // Import review tools for side effects (registers subagent tool handlers)
42
42
  import "../tools/review";
43
43
  import type { AsyncJobManager } from "../async";
44
+ import { hasResolvableTranscript } from "../internal-urls/registry-helpers";
44
45
  import { AgentRegistry } from "../registry/agent-registry";
45
46
  import { type DiscoveryResult, discoverAgents } from "./discovery";
46
47
  import { generateTaskName } from "./name-generator";
@@ -160,6 +161,7 @@ export function formatResultOutputFallback(result: Pick<SingleResult, "output" |
160
161
  function renderDescription(
161
162
  agents: AgentDefinition[],
162
163
  isolationEnabled: boolean,
164
+ applyIsolatedChanges: boolean,
163
165
  disabledAgents: string[],
164
166
  batchEnabled: boolean,
165
167
  asyncEnabled: boolean,
@@ -187,6 +189,7 @@ function renderDescription(
187
189
  defaultAgent: spawnPolicy.defaultAgent,
188
190
  allowedAgentsText: spawnPolicy.allowedPromptText,
189
191
  isolationEnabled,
192
+ applyIsolatedChanges,
190
193
  batchEnabled,
191
194
  asyncEnabled,
192
195
  hasBlockingAgents: renderedAgents.some(agent => agent.blocking),
@@ -229,6 +232,19 @@ function validateShapeParams(batchEnabled: boolean, params: TaskParams): string
229
232
  * policy later, in `spawnParamsFor`. Returns a problem description, or
230
233
  * undefined when valid.
231
234
  */
235
+ function hasInvalidModelSelector(model: unknown): boolean {
236
+ if (model === undefined) return false;
237
+ const selectors = typeof model === "string" ? [model] : Array.isArray(model) ? model : undefined;
238
+ const materializedSelectors = selectors ? Array.from(selectors) : [];
239
+ return (
240
+ !selectors ||
241
+ materializedSelectors.length === 0 ||
242
+ materializedSelectors.some(
243
+ selector => typeof selector !== "string" || !selector.split(",").some(pattern => pattern.trim()),
244
+ )
245
+ );
246
+ }
247
+
232
248
  function validateSpawnParams(params: TaskParams, batchEnabled: boolean): string | undefined {
233
249
  const hasTask = typeof params.task === "string" && params.task.trim() !== "";
234
250
  const tasks = params.tasks;
@@ -244,6 +260,9 @@ function validateSpawnParams(params: TaskParams, batchEnabled: boolean): string
244
260
  if (!item || typeof item.task !== "string" || item.task.trim() === "") {
245
261
  return `Task ${i + 1}${item?.name ? ` (\`${item.name}\`)` : ""} is missing \`task\`. Every task needs complete, self-contained instructions.`;
246
262
  }
263
+ if (hasInvalidModelSelector(item.model)) {
264
+ return `Task ${i + 1}${item.name ? ` (\`${item.name}\`)` : ""} has an invalid \`model\`. Provide a non-empty selector or a non-empty array of non-empty selectors.`;
265
+ }
247
266
  }
248
267
  const seen = new Map<string, string>();
249
268
  for (const item of tasks) {
@@ -266,6 +285,9 @@ function validateSpawnParams(params: TaskParams, batchEnabled: boolean): string
266
285
  ? "Missing `tasks`. Provide a `tasks` array (one subagent per item) with a shared `context`."
267
286
  : "Missing `task`. Provide complete, self-contained instructions for the agent.";
268
287
  }
288
+ if (hasInvalidModelSelector(params.model)) {
289
+ return "Invalid `model`. Provide a non-empty selector or a non-empty array of non-empty selectors.";
290
+ }
269
291
  return undefined;
270
292
  }
271
293
 
@@ -279,7 +301,7 @@ function resolveSpawnItems(params: TaskParams): TaskItem[] {
279
301
  if (Array.isArray(params.tasks) && params.tasks.length > 0) {
280
302
  return params.tasks;
281
303
  }
282
- const item: TaskItem = { name: params.name, agent: params.agent, task: params.task };
304
+ const item: TaskItem = { name: params.name, agent: params.agent, task: params.task, model: params.model };
283
305
  if ("outputSchema" in params) item.outputSchema = params.outputSchema;
284
306
  if ("schemaMode" in params) item.schemaMode = params.schemaMode;
285
307
  if ("isolated" in params) item.isolated = params.isolated;
@@ -299,6 +321,7 @@ function spawnParamsFor(params: TaskParams, item: TaskItem, defaultAgent: string
299
321
  const spawn: TaskParams = { agent: item.agent?.trim() || defaultAgent };
300
322
  if (item.name !== undefined) spawn.name = item.name;
301
323
  if (item.task !== undefined) spawn.task = item.task;
324
+ if (item.model !== undefined) spawn.model = item.model;
302
325
  if (params.context !== undefined) spawn.context = params.context;
303
326
  if ("outputSchema" in item) spawn.outputSchema = item.outputSchema;
304
327
  if ("schemaMode" in item) spawn.schemaMode = item.schemaMode;
@@ -469,6 +492,14 @@ function discoverAgentsForCreate(cwd: string): Promise<DiscoveryResult> {
469
492
  return pending;
470
493
  }
471
494
 
495
+ function formatModelForApproval(model: unknown): string | undefined {
496
+ const selectors = typeof model === "string" ? [model] : Array.isArray(model) ? model : [];
497
+ const normalized = selectors.filter(
498
+ (selector): selector is string => typeof selector === "string" && !!selector.trim(),
499
+ );
500
+ return normalized.length > 0 ? truncateForPrompt(normalized.join(" → ")) : undefined;
501
+ }
502
+
472
503
  // ═══════════════════════════════════════════════════════════════════════════
473
504
  // Tool Class
474
505
  // ═══════════════════════════════════════════════════════════════════════════
@@ -492,6 +523,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
492
523
  if (typeof params.name === "string" && params.name.trim()) {
493
524
  lines.push(`Name: ${truncateForPrompt(params.name)}`);
494
525
  }
526
+ const model = formatModelForApproval(params.model);
527
+ if (model) lines.push(`Model: ${model}`);
495
528
  if (typeof params.task === "string") {
496
529
  lines.push(`Task:\n${truncateForPrompt(params.task)}`);
497
530
  }
@@ -507,6 +540,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
507
540
  if (typeof firstTask.agent === "string" && firstTask.agent.trim()) {
508
541
  lines.push(`Agent: ${truncateForPrompt(firstTask.agent)}`);
509
542
  }
543
+ const itemModel = formatModelForApproval(firstTask.model);
544
+ if (itemModel) lines.push(`Model: ${itemModel}`);
510
545
  if (typeof firstTask.task === "string") {
511
546
  lines.push(`Task:\n${truncateForPrompt(firstTask.task)}`);
512
547
  }
@@ -566,6 +601,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
566
601
  return renderDescription(
567
602
  this.#discoveredAgents,
568
603
  !planMode && isolationMode !== "none",
604
+ this.session.settings.get("task.isolation.apply"),
569
605
  disabledAgents,
570
606
  this.#isBatchEnabled(),
571
607
  this.session.settings.get("async.enabled"),
@@ -612,6 +648,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
612
648
  assignment: (params.task ?? "").trim(),
613
649
  context: this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined,
614
650
  agent: params.agent,
651
+ model: params.model,
615
652
  ...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
616
653
  ...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
617
654
  ...("isolated" in params ? { isolation: { requested: params.isolated } } : {}),
@@ -1043,14 +1080,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1043
1080
  }): string {
1044
1081
  const { manager, toolCallId, spawnParams, agentId, progress, ircEnabled, buildDetails, onUpdate, onSettled } =
1045
1082
  options;
1046
- const buildFollowUpHint = (aborted: boolean): string => {
1083
+ const buildFollowUpHint = async (aborted: boolean): Promise<string> => {
1047
1084
  if (aborted) {
1048
- const status = AgentRegistry.global().get(agentId)?.status;
1049
- if (status === "idle" || status === "parked") {
1085
+ const ref = AgentRegistry.global().get(agentId);
1086
+ const transcript = (await hasResolvableTranscript(agentId))
1087
+ ? `transcript at history://${agentId}`
1088
+ : "transcript unavailable";
1089
+ if (ref?.status === "idle" || ref?.status === "parked") {
1050
1090
  const followUp = ircEnabled ? "message it via `hub` to resume; " : "";
1051
- return `\n\n${agentId} was stopped but is still resumable — ${followUp}transcript at history://${agentId}`;
1091
+ return `\n\n${agentId} was stopped but is still resumable — ${followUp}${transcript}`;
1052
1092
  }
1053
- return `\n\n${agentId} was aborted — transcript at history://${agentId}`;
1093
+ return `\n\n${agentId} was aborted — ${transcript}`;
1054
1094
  }
1055
1095
  const followUp = ircEnabled ? "message it via `hub` to follow up; " : "";
1056
1096
  return `\n\n${agentId} is now idle — ${followUp}transcript at history://${agentId}`;
@@ -1105,6 +1145,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1105
1145
  // and running counters without reverting the "running"
1106
1146
  // status back to the subagent's initial "pending" snapshot.
1107
1147
  progress.resolvedModel = nextProgress.resolvedModel;
1148
+ progress.resolvedModelIsFallback = nextProgress.resolvedModelIsFallback;
1108
1149
  progress.tokens = nextProgress.tokens;
1109
1150
  progress.requests = nextProgress.requests;
1110
1151
  progress.contextTokens = nextProgress.contextTokens;
@@ -1149,15 +1190,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1149
1190
  progress.retryState = undefined;
1150
1191
  if (singleResult?.resolvedModel) {
1151
1192
  progress.resolvedModel = singleResult.resolvedModel;
1193
+ progress.resolvedModelIsFallback = singleResult.resolvedModelIsFallback;
1152
1194
  } else {
1153
1195
  delete progress.resolvedModel;
1196
+ delete progress.resolvedModelIsFallback;
1154
1197
  }
1155
1198
  onSettled?.(resultFailed);
1156
1199
  const statusText = resultFailed
1157
1200
  ? `Background task ${agentId} failed.`
1158
1201
  : `Background task ${agentId} complete.`;
1159
1202
  await reportProgress(statusText, buildDetails() as unknown as Record<string, unknown>);
1160
- const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`;
1203
+ const deliveryText = `${finalText}${await buildFollowUpHint(singleResult?.aborted === true)}`;
1161
1204
  if (resultFailed) {
1162
1205
  // Mark the job itself failed; the failed agent stays interrogable.
1163
1206
  throw new TaskJobError(deliveryText);
@@ -1173,7 +1216,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1173
1216
  const statusText = `Background task ${agentId} failed.`;
1174
1217
  await reportProgress(statusText, buildDetails() as unknown as Record<string, unknown>);
1175
1218
  const message = error instanceof Error ? error.message : String(error);
1176
- const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : "";
1219
+ const hint = AgentRegistry.global().get(agentId) ? await buildFollowUpHint(false) : "";
1177
1220
  throw new TaskJobError(`${message}${hint}`);
1178
1221
  } finally {
1179
1222
  releasePermit();
@@ -1383,6 +1426,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1383
1426
  assignment,
1384
1427
  context,
1385
1428
  agent: params.agent,
1429
+ model: params.model,
1386
1430
  ...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
1387
1431
  ...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
1388
1432
  identity: { id: preAllocatedId, label: params.name },
@@ -300,7 +300,9 @@ export async function resolveEffectiveSubagentPolicy(
300
300
  planMode,
301
301
  isIsolated,
302
302
  mergeMode: request.isolation?.merge ?? request.session.settings.get("task.isolation.merge"),
303
- applyChanges: request.isolation?.apply !== false,
303
+ applyChanges:
304
+ request.isolation?.apply ??
305
+ (request.invocationKind === "task" ? request.session.settings.get("task.isolation.apply") : true),
304
306
  enableLsp:
305
307
  !planMode &&
306
308
  (request.enableLsp ?? ((request.session.enableLsp ?? true) && request.session.settings.get("task.enableLsp"))),
package/src/task/types.ts CHANGED
@@ -113,6 +113,7 @@ export const taskItemSchema = type({
113
113
  "name?": "string",
114
114
  agent: "string = 'task'",
115
115
  task: "string",
116
+ "model?": "string | string[]",
116
117
  "outputSchema?": outputSchemaInputSchema,
117
118
  "schemaMode?": '"permissive" | "strict"',
118
119
  "+": "delete",
@@ -121,6 +122,7 @@ const taskItemSchemaIsolated = type({
121
122
  "name?": "string",
122
123
  agent: "string = 'task'",
123
124
  task: "string",
125
+ "model?": "string | string[]",
124
126
  "outputSchema?": outputSchemaInputSchema,
125
127
  "schemaMode?": '"permissive" | "strict"',
126
128
  "isolated?": "boolean",
@@ -135,6 +137,8 @@ export interface TaskItem {
135
137
  agent?: string;
136
138
  /** The work; required by the schema. */
137
139
  task?: string;
140
+ /** Explicit model selector or fallback chain for this spawn, including optional reasoning suffixes. */
141
+ model?: string | string[];
138
142
  /** Caller-provided output schema; its presence overrides the selected agent's schema. */
139
143
  outputSchema?: unknown;
140
144
  /** Validation behavior for a caller-provided or inherited output schema. */
@@ -147,6 +151,7 @@ export const taskSchema = type({
147
151
  "name?": "string",
148
152
  agent: "string = 'task'",
149
153
  task: "string",
154
+ "model?": "string | string[]",
150
155
  "outputSchema?": outputSchemaInputSchema,
151
156
  "schemaMode?": '"permissive" | "strict"',
152
157
  "isolated?": "boolean",
@@ -156,6 +161,7 @@ const taskSchemaNoIsolation = type({
156
161
  "name?": "string",
157
162
  agent: "string = 'task'",
158
163
  task: "string",
164
+ "model?": "string | string[]",
159
165
  "outputSchema?": outputSchemaInputSchema,
160
166
  "schemaMode?": '"permissive" | "strict"',
161
167
  "+": "delete",
@@ -200,6 +206,7 @@ function createTaskSchema(options: {
200
206
  "name?": "string",
201
207
  agent,
202
208
  task: "string",
209
+ "model?": "string | string[]",
203
210
  "outputSchema?": outputSchemaInputSchema,
204
211
  "schemaMode?": '"permissive" | "strict"',
205
212
  "isolated?": "boolean",
@@ -215,6 +222,7 @@ function createTaskSchema(options: {
215
222
  "name?": "string",
216
223
  agent,
217
224
  task: "string",
225
+ "model?": "string | string[]",
218
226
  "outputSchema?": outputSchemaInputSchema,
219
227
  "schemaMode?": '"permissive" | "strict"',
220
228
  "+": "delete",
@@ -230,6 +238,7 @@ function createTaskSchema(options: {
230
238
  "name?": "string",
231
239
  agent,
232
240
  task: "string",
241
+ "model?": "string | string[]",
233
242
  "outputSchema?": outputSchemaInputSchema,
234
243
  "schemaMode?": '"permissive" | "strict"',
235
244
  "isolated?": "boolean",
@@ -240,6 +249,7 @@ function createTaskSchema(options: {
240
249
  "name?": "string",
241
250
  agent,
242
251
  task: "string",
252
+ "model?": "string | string[]",
243
253
  "outputSchema?": outputSchemaInputSchema,
244
254
  "schemaMode?": '"permissive" | "strict"',
245
255
  "+": "delete",
@@ -283,6 +293,8 @@ export interface TaskParams {
283
293
  agent?: string;
284
294
  /** The work (flat form). */
285
295
  task?: string;
296
+ /** Explicit model selector or fallback chain for the spawn, including optional reasoning suffixes. */
297
+ model?: string | string[];
286
298
  /** Caller-provided output schema; its presence overrides the selected agent's schema. */
287
299
  outputSchema?: unknown;
288
300
  /** Validation behavior for a caller-provided or inherited output schema. */
@@ -419,6 +431,8 @@ export interface AgentProgress {
419
431
  modelOverride?: string | string[];
420
432
  /** Resolved model display string in the form `<provider>/<id>`, optionally suffixed with `:<thinkingLevel>` when the level was set explicitly. Undefined when the model could not be resolved. */
421
433
  resolvedModel?: string;
434
+ /** True when {@link resolvedModel} is the target of an active retry fallback (not the originally configured model). Lets observer-only UIs (collab guests, Agent Hub rows with no live session) flag the fallback and keep the provider. */
435
+ resolvedModelIsFallback?: boolean;
422
436
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
423
437
  extractedToolData?: Record<string, unknown[]>;
424
438
  /**
@@ -486,6 +500,8 @@ export interface SingleResult {
486
500
  modelOverride?: string | string[];
487
501
  /** Resolved model display string in the form `<provider>/<id>`, optionally suffixed with `:<thinkingLevel>` when the level was set explicitly. Omitted from tool-result JSON when undefined to keep wire payloads small. */
488
502
  resolvedModel?: string;
503
+ /** True when {@link resolvedModel} is the target of an active retry fallback. Mirrors {@link AgentProgress.resolvedModelIsFallback} onto the settled result. */
504
+ resolvedModelIsFallback?: boolean;
489
505
  error?: string;
490
506
  aborted?: boolean;
491
507
  abortReason?: string;
@@ -283,7 +283,7 @@ export class HubTool implements AgentTool<typeof hubSchema, HubDetails> {
283
283
  if (!params.ids?.length) {
284
284
  return hubErrorResult('`ids` is required for op="cancel".', { op: "cancel", jobs: [] });
285
285
  }
286
- return executeCancel(this.session, manager, this.#ownerId(), params.ids);
286
+ return await executeCancel(this.session, manager, this.#ownerId(), params.ids);
287
287
  }
288
288
  case "jobs": {
289
289
  const manager = this.session.asyncJobManager;