@oh-my-pi/pi-coding-agent 17.0.7 → 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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
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
  }
@@ -520,6 +555,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
520
555
  readonly summary = "Spawn subagents to complete delegated tasks";
521
556
  readonly strict = false;
522
557
  readonly loadMode = "essential";
558
+ // Arktype validates model calls against the active wire schema, but the flat
559
+ // single-spawn schema carries `"+": "delete"`: a batch `{ context, tasks[] }`
560
+ // payload has those keys stripped, then fails on the now-missing `task` with
561
+ // the misleading `task must be a string (was missing)`. That preempts the
562
+ // tool's own actionable shape checks (`validateShapeParams` /
563
+ // `validateSpawnParams`), which never run. Lenient validation forwards the
564
+ // raw args to `execute()` on any arktype failure so those checks surface the
565
+ // real reason ("enable task.batch, or use the flat `task` shape"). Valid
566
+ // calls still normalize through arktype; `execute()` resolves `agent`
567
+ // defaults independently, so the success path is unchanged.
568
+ readonly lenientArgValidation = true;
523
569
  readonly renderResult = renderResult;
524
570
  // Suppress the streaming call preview once a (partial or final) result exists
525
571
  // so the task renders as ONE block that transitions in place — not a pending
@@ -555,6 +601,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
555
601
  return renderDescription(
556
602
  this.#discoveredAgents,
557
603
  !planMode && isolationMode !== "none",
604
+ this.session.settings.get("task.isolation.apply"),
558
605
  disabledAgents,
559
606
  this.#isBatchEnabled(),
560
607
  this.session.settings.get("async.enabled"),
@@ -601,6 +648,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
601
648
  assignment: (params.task ?? "").trim(),
602
649
  context: this.#isBatchEnabled() ? params.context?.trim() || undefined : undefined,
603
650
  agent: params.agent,
651
+ model: params.model,
604
652
  ...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
605
653
  ...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
606
654
  ...("isolated" in params ? { isolation: { requested: params.isolated } } : {}),
@@ -1032,14 +1080,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1032
1080
  }): string {
1033
1081
  const { manager, toolCallId, spawnParams, agentId, progress, ircEnabled, buildDetails, onUpdate, onSettled } =
1034
1082
  options;
1035
- const buildFollowUpHint = (aborted: boolean): string => {
1083
+ const buildFollowUpHint = async (aborted: boolean): Promise<string> => {
1036
1084
  if (aborted) {
1037
- const status = AgentRegistry.global().get(agentId)?.status;
1038
- 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") {
1039
1090
  const followUp = ircEnabled ? "message it via `hub` to resume; " : "";
1040
- 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}`;
1041
1092
  }
1042
- return `\n\n${agentId} was aborted — transcript at history://${agentId}`;
1093
+ return `\n\n${agentId} was aborted — ${transcript}`;
1043
1094
  }
1044
1095
  const followUp = ircEnabled ? "message it via `hub` to follow up; " : "";
1045
1096
  return `\n\n${agentId} is now idle — ${followUp}transcript at history://${agentId}`;
@@ -1094,6 +1145,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1094
1145
  // and running counters without reverting the "running"
1095
1146
  // status back to the subagent's initial "pending" snapshot.
1096
1147
  progress.resolvedModel = nextProgress.resolvedModel;
1148
+ progress.resolvedModelIsFallback = nextProgress.resolvedModelIsFallback;
1097
1149
  progress.tokens = nextProgress.tokens;
1098
1150
  progress.requests = nextProgress.requests;
1099
1151
  progress.contextTokens = nextProgress.contextTokens;
@@ -1138,15 +1190,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1138
1190
  progress.retryState = undefined;
1139
1191
  if (singleResult?.resolvedModel) {
1140
1192
  progress.resolvedModel = singleResult.resolvedModel;
1193
+ progress.resolvedModelIsFallback = singleResult.resolvedModelIsFallback;
1141
1194
  } else {
1142
1195
  delete progress.resolvedModel;
1196
+ delete progress.resolvedModelIsFallback;
1143
1197
  }
1144
1198
  onSettled?.(resultFailed);
1145
1199
  const statusText = resultFailed
1146
1200
  ? `Background task ${agentId} failed.`
1147
1201
  : `Background task ${agentId} complete.`;
1148
1202
  await reportProgress(statusText, buildDetails() as unknown as Record<string, unknown>);
1149
- const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`;
1203
+ const deliveryText = `${finalText}${await buildFollowUpHint(singleResult?.aborted === true)}`;
1150
1204
  if (resultFailed) {
1151
1205
  // Mark the job itself failed; the failed agent stays interrogable.
1152
1206
  throw new TaskJobError(deliveryText);
@@ -1162,7 +1216,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1162
1216
  const statusText = `Background task ${agentId} failed.`;
1163
1217
  await reportProgress(statusText, buildDetails() as unknown as Record<string, unknown>);
1164
1218
  const message = error instanceof Error ? error.message : String(error);
1165
- const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : "";
1219
+ const hint = AgentRegistry.global().get(agentId) ? await buildFollowUpHint(false) : "";
1166
1220
  throw new TaskJobError(`${message}${hint}`);
1167
1221
  } finally {
1168
1222
  releasePermit();
@@ -1372,6 +1426,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1372
1426
  assignment,
1373
1427
  context,
1374
1428
  agent: params.agent,
1429
+ model: params.model,
1375
1430
  ...(Object.hasOwn(params, "outputSchema") ? { outputSchema: params.outputSchema } : {}),
1376
1431
  ...(Object.hasOwn(params, "schemaMode") ? { schemaMode: params.schemaMode } : {}),
1377
1432
  identity: { id: preAllocatedId, label: params.name },
@@ -0,0 +1,6 @@
1
+ import type { AgentDefinition } from "./types";
2
+
3
+ /** Resolve an agent's prewalk default, including the bundled task opt-in. */
4
+ export function resolveAgentPrewalkDefault(agent: AgentDefinition, taskPrewalk: boolean): boolean | string | undefined {
5
+ return agent.prewalk ?? (taskPrewalk && agent.source === "bundled" && agent.name === "task" ? true : undefined);
6
+ }
@@ -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;
@@ -116,7 +116,16 @@ async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
116
116
  return { repoRoot, headCommit, staged, unstaged, untracked, untrackedPatch };
117
117
  }
118
118
 
119
- async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches: readonly string[]): Promise<string> {
119
+ interface SyntheticTreeOptions {
120
+ readonly threeWay?: boolean;
121
+ }
122
+
123
+ async function writeSyntheticTree(
124
+ repoDir: string,
125
+ baseTreeish: string,
126
+ patches: readonly string[],
127
+ options: SyntheticTreeOptions = {},
128
+ ): Promise<string> {
120
129
  const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
121
130
  try {
122
131
  await git.readTree(repoDir, baseTreeish, {
@@ -127,6 +136,7 @@ async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches:
127
136
  await git.patch.applyText(repoDir, patch, {
128
137
  cached: true,
129
138
  env: { GIT_INDEX_FILE: tempIndex },
139
+ threeWay: options.threeWay,
130
140
  });
131
141
  }
132
142
  return await git.writeTree(repoDir, {
@@ -653,11 +663,12 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
653
663
  try {
654
664
  await git.worktree.add(opts.repoRoot, tmpDir, opts.branchName);
655
665
  const agentCommits = await git.revList.range(opts.isolationDir, baselineSha, opts.isolationHead);
656
- const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, [
657
- opts.baseline.root.staged,
658
- opts.baseline.root.unstaged,
659
- opts.baseline.root.untrackedPatch,
660
- ]);
666
+ const baselineWip = [opts.baseline.root.staged, opts.baseline.root.unstaged, opts.baseline.root.untrackedPatch];
667
+ // Seed the parent ODB with the dirty-side blobs needed by `git apply
668
+ // --3way`. Isolation repositories can read parent objects, but the parent
669
+ // cannot read objects created only inside isolation.
670
+ await writeSyntheticTree(opts.repoRoot, baselineSha, baselineWip);
671
+ const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, baselineWip);
661
672
  let previousFilteredTree = baselineSha;
662
673
  let filteredCommitsApplied = 0;
663
674
 
@@ -666,7 +677,9 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
666
677
  allowFailure: true,
667
678
  binary: true,
668
679
  });
669
- const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch]);
680
+ const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch], {
681
+ threeWay: true,
682
+ });
670
683
  const commitPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, currentFilteredTree, {
671
684
  allowFailure: true,
672
685
  binary: true,
@@ -699,10 +712,11 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
699
712
  await commitPatchToBranchWorktree(tmpDir, opts.taskId, opts.rootPatch, msg, undefined, opts.baseline.root);
700
713
  }
701
714
  } else {
702
- // A filtered commit landed; tmpDir has advanced past baselineSha and
703
- // previousFilteredTree is HEAD-derived, so writeSyntheticTree +
704
- // leftoverPatch stay HEAD-based and no WIP seed is needed.
705
- const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
715
+ // A filtered commit landed; reconstruct the final HEAD-derived tree
716
+ // with the same dirty-side blobs and 3-way synthesis used above.
717
+ const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch], {
718
+ threeWay: true,
719
+ });
706
720
  const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
707
721
  allowFailure: true,
708
722
  binary: true,
package/src/tools/ask.ts CHANGED
@@ -81,6 +81,21 @@ const askSchema = arkType({
81
81
 
82
82
  export type AskToolInput = typeof askSchema.infer;
83
83
 
84
+ /**
85
+ * Recover a validated `questions` payload from a persisted `ask` toolCall's
86
+ * `arguments`. Used by `/tree` re-answer (issue #5642): selecting a past
87
+ * `ask` toolResult re-opens the picker with the *original* questions, so the
88
+ * new answer branches as a sibling instead of mutating the old one. Runs the
89
+ * same schema the live tool call validated against — legacy/corrupted
90
+ * persisted args fail closed (`undefined`) rather than feeding malformed
91
+ * data back into the picker.
92
+ */
93
+ export function recoverAskQuestions(toolCallArguments: unknown): AskToolInput["questions"] | undefined {
94
+ const parsed = askSchema(toolCallArguments);
95
+ if (parsed instanceof arkType.errors) return undefined;
96
+ return parsed.questions;
97
+ }
98
+
84
99
  /** Result for a single question */
85
100
  export interface QuestionResult {
86
101
  id: string;
package/src/tools/bash.ts CHANGED
@@ -312,10 +312,17 @@ function extractPartialBashEnv(partialJson: string | undefined): Record<string,
312
312
  return Object.keys(env).length > 0 ? env : undefined;
313
313
  }
314
314
 
315
- function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutSec: number): string | undefined {
316
- return requestedTimeoutSec !== effectiveTimeoutSec
317
- ? `Timeout clamped to ${effectiveTimeoutSec}s (requested ${requestedTimeoutSec}s; allowed range ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}s).`
318
- : undefined;
315
+ function formatTimeoutClampNotice(
316
+ requestedTimeoutSec: number,
317
+ effectiveTimeoutSec: number,
318
+ maxTimeout: number,
319
+ ): string | undefined {
320
+ if (requestedTimeoutSec === effectiveTimeoutSec) return undefined;
321
+ const cappedByGlobal = maxTimeout > 0 && effectiveTimeoutSec === maxTimeout && maxTimeout < TOOL_TIMEOUTS.bash.max;
322
+ const limit = cappedByGlobal
323
+ ? `global tools.maxTimeout ceiling ${maxTimeout}s`
324
+ : `allowed range ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}s`;
325
+ return `Timeout clamped to ${effectiveTimeoutSec}s (requested ${requestedTimeoutSec}s; ${limit}).`;
319
326
  }
320
327
 
321
328
  function formatWallTimeSeconds(wallTimeMs: number): string {
@@ -831,11 +838,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
831
838
  // must still cancel the call or job, but OMP does not impose a deadline.
832
839
  const requestedTimeoutSec = rawTimeout;
833
840
  const timeoutDisabled = requestedTimeoutSec === 0;
834
- const timeoutSec = timeoutDisabled ? undefined : clampTimeout("bash", requestedTimeoutSec);
841
+ const maxTimeout = this.session.settings.get("tools.maxTimeout");
842
+ const timeoutSec = timeoutDisabled ? undefined : clampTimeout("bash", requestedTimeoutSec, maxTimeout);
835
843
  const timeoutMs = timeoutSec === undefined ? undefined : timeoutSec * 1000;
836
844
  const pendingNotices: string[] = [];
837
845
  if (timeoutSec !== undefined) {
838
- const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
846
+ const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec, maxTimeout);
839
847
  if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
840
848
  }
841
849
 
@@ -190,7 +190,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
190
190
  ): Promise<AgentToolResult<BrowserToolDetails>> {
191
191
  try {
192
192
  throwIfAborted(signal);
193
- const timeoutSeconds = clampTimeout("browser", params.timeout);
193
+ const timeoutSeconds = clampTimeout("browser", params.timeout, this.session.settings.get("tools.maxTimeout"));
194
194
  const timeoutMs = timeoutSeconds * 1000;
195
195
  const name = params.name ?? DEFAULT_TAB_NAME;
196
196
  const details: BrowserToolDetails = { action: params.action, name };
@@ -729,7 +729,7 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
729
729
  _onUpdate?: AgentToolUpdateCallback<DebugToolDetails>,
730
730
  _context?: AgentToolContext,
731
731
  ): Promise<AgentToolResult<DebugToolDetails>> {
732
- const timeoutSec = clampTimeout("debug", params.timeout);
732
+ const timeoutSec = clampTimeout("debug", params.timeout, this.session.settings.get("tools.maxTimeout"));
733
733
  const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
734
734
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
735
735
  const details: DebugToolDetails = { action: params.action, success: true };
package/src/tools/eval.ts CHANGED
@@ -217,10 +217,6 @@ function detailsNotice(cells: ResolvedEvalCell[]): string | undefined {
217
217
  return notices.length > 0 ? notices.join(" ") : undefined;
218
218
  }
219
219
 
220
- function timeoutSecondsFromMs(timeoutMs: number): number {
221
- return clampTimeout("eval", timeoutMs / 1000);
222
- }
223
-
224
220
  async function resolveBackend(session: ToolSession, language: EvalLanguage): Promise<ResolvedBackend> {
225
221
  const backends = resolveEvalBackends(session);
226
222
  const allowPy = backends.python;
@@ -538,7 +534,10 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
538
534
  // ordinary tool calls all count against the budget. The watchdog drives
539
535
  // `combinedSignal`; we pass no wall-clock deadline downstream so the
540
536
  // backends never arm a competing fixed timer.
541
- const idleTimeoutMs = cell.timeoutMs === 0 ? undefined : timeoutSecondsFromMs(cell.timeoutMs) * 1000;
537
+ const idleTimeoutMs =
538
+ cell.timeoutMs === 0
539
+ ? undefined
540
+ : clampTimeout("eval", cell.timeoutMs / 1000, session.settings.get("tools.maxTimeout")) * 1000;
542
541
  const idle = idleTimeoutMs === undefined ? undefined : new IdleTimeout(idleTimeoutMs);
543
542
  const combinedSignal =
544
543
  signal && idle
@@ -1622,7 +1622,7 @@ export async function fetchReadUrl(
1622
1622
  ): Promise<ReadUrlEntry> {
1623
1623
  const { path: url, raw = false } = params;
1624
1624
 
1625
- const effectiveTimeout = clampTimeout("fetch", 30);
1625
+ const effectiveTimeout = clampTimeout("fetch", 30, session.settings.get("tools.maxTimeout"));
1626
1626
 
1627
1627
  if (signal?.aborted) {
1628
1628
  throw new ToolAbortError();
@@ -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;
@@ -12,6 +12,7 @@ import { settings } from "../../config/settings";
12
12
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
13
13
  import { shimmerEnabled, shimmerText } from "../../modes/theme/shimmer";
14
14
  import type { Theme } from "../../modes/theme/theme";
15
+ import { USER_INTERRUPT_LABEL } from "../../session/messages";
15
16
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../../tui";
16
17
  import type { ToolSession } from "..";
17
18
  import {
@@ -304,26 +305,38 @@ export function nothingToWaitForResult(session: ToolSession): AgentToolResult<Co
304
305
  }
305
306
 
306
307
  /** `cancel`: kill the named jobs; returns immediately with outcomes + snapshots. */
307
- export function executeCancel(
308
+ export async function executeCancel(
308
309
  session: ToolSession,
309
310
  manager: AsyncJobManager,
310
311
  ownerId: string | undefined,
311
312
  ids: string[],
312
- ): AgentToolResult<CoordinationDetails> {
313
+ ): Promise<AgentToolResult<CoordinationDetails>> {
313
314
  const ownerFilter = ownerId ? { ownerId } : undefined;
314
315
  const cancelOutcomes: CancelOutcome[] = [];
315
316
  for (const id of ids) {
316
317
  const existing = manager.getJob(id);
317
318
  if (!existing || (ownerId && existing.ownerId !== ownerId)) {
318
- cancelOutcomes.push({ id, status: "not_found", message: `Background job not found: ${id}` });
319
+ // No job by this id (or it belongs to another agent): a budget-aborted
320
+ // keep-alive subagent lives on as a jobless registration long after its
321
+ // job row is reaped, so let cancel reach the agent registration too.
322
+ cancelOutcomes.push(await cancelAgentRegistration(session, ownerId, id));
319
323
  continue;
320
324
  }
321
325
  if (existing.status !== "running") {
322
- cancelOutcomes.push({
323
- id,
324
- status: "already_completed",
325
- message: `Background job ${id} is already ${existing.status}.`,
326
- });
326
+ // The job row settled but may still be inside the retention window.
327
+ // The agent registration behind it (job id == agent id for task
328
+ // spawns) can outlive the row as an idle/parked zombie — try the
329
+ // registration kill before reporting the row as already done.
330
+ const regOutcome = await cancelAgentRegistration(session, ownerId, id);
331
+ cancelOutcomes.push(
332
+ regOutcome.status === "cancelled"
333
+ ? regOutcome
334
+ : {
335
+ id,
336
+ status: "already_completed",
337
+ message: `Background job ${id} is already ${existing.status}.`,
338
+ },
339
+ );
327
340
  continue;
328
341
  }
329
342
  const cancelled = manager.cancel(id, ownerFilter);
@@ -336,6 +349,52 @@ export function executeCancel(
336
349
  return buildJobResult(session, manager, "cancel", visibleJobs(manager, ids, ownerId), cancelOutcomes);
337
350
  }
338
351
 
352
+ /**
353
+ * Kill a non-job-backed agent registration named by `id`: abort any in-flight
354
+ * turn, then release it from the lifecycle (dispose session + unregister). This
355
+ * is the only kill path for a keep-alive subagent that was budget-aborted, went
356
+ * `idle`/`parked`, and outlived its job row — otherwise it is unstoppable short
357
+ * of a broker restart (issue #6315). Scoped to the caller's own descendants so
358
+ * cross-agent kills stay impossible; a bare test/SDK caller (no owner id) may
359
+ * target any sub. Never touches Main, the caller, or advisor transcripts.
360
+ */
361
+ async function cancelAgentRegistration(
362
+ session: ToolSession,
363
+ ownerId: string | undefined,
364
+ id: string,
365
+ ): Promise<CancelOutcome> {
366
+ const registry = session.agentRegistry;
367
+ const ref = registry?.get(id);
368
+ if (ref?.kind !== "sub") {
369
+ return { id, status: "not_found", message: `Background job not found: ${id}` };
370
+ }
371
+ if (id === ownerId) {
372
+ return { id, status: "not_found", message: `Cannot cancel yourself (${id}).` };
373
+ }
374
+ if (ownerId && ref.parentId !== ownerId) {
375
+ return { id, status: "not_found", message: `Agent ${id} was not spawned by you and cannot be cancelled.` };
376
+ }
377
+ const lifecycle = session.agentLifecycle?.();
378
+ try {
379
+ if (ref.status === "running" && ref.session) {
380
+ await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
381
+ }
382
+ if (lifecycle) {
383
+ await lifecycle.release(id);
384
+ } else {
385
+ await ref.session?.dispose();
386
+ registry?.unregister(id);
387
+ }
388
+ } catch (error) {
389
+ return {
390
+ id,
391
+ status: "already_completed",
392
+ message: `Agent ${id} could not be fully cancelled: ${error instanceof Error ? error.message : String(error)}.`,
393
+ };
394
+ }
395
+ return { id, status: "cancelled", message: `Cancelled agent ${id} (killed session, dropped registration).` };
396
+ }
397
+
339
398
  /** `jobs`: read-only snapshot of every job plus the jobless running-agent roster. */
340
399
  export function executeJobsSnapshot(
341
400
  session: ToolSession,