@oh-my-pi/pi-coding-agent 13.5.7 → 13.6.0

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 (49) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/package.json +7 -7
  3. package/src/cli/args.ts +7 -0
  4. package/src/cli/stats-cli.ts +5 -0
  5. package/src/config/model-registry.ts +99 -9
  6. package/src/config/settings-schema.ts +22 -2
  7. package/src/extensibility/extensions/types.ts +2 -0
  8. package/src/internal-urls/docs-index.generated.ts +2 -2
  9. package/src/internal-urls/index.ts +2 -1
  10. package/src/internal-urls/mcp-protocol.ts +156 -0
  11. package/src/internal-urls/router.ts +1 -1
  12. package/src/internal-urls/types.ts +3 -3
  13. package/src/ipy/prelude.py +1 -0
  14. package/src/mcp/client.ts +235 -2
  15. package/src/mcp/index.ts +1 -1
  16. package/src/mcp/manager.ts +399 -5
  17. package/src/mcp/oauth-flow.ts +26 -1
  18. package/src/mcp/smithery-auth.ts +104 -0
  19. package/src/mcp/smithery-connect.ts +145 -0
  20. package/src/mcp/smithery-registry.ts +455 -0
  21. package/src/mcp/types.ts +140 -0
  22. package/src/modes/components/footer.ts +10 -4
  23. package/src/modes/components/settings-defs.ts +15 -1
  24. package/src/modes/components/status-line/git-utils.ts +42 -0
  25. package/src/modes/components/status-line/presets.ts +6 -6
  26. package/src/modes/components/status-line/segments.ts +27 -4
  27. package/src/modes/components/status-line/types.ts +2 -0
  28. package/src/modes/components/status-line-segment-editor.ts +1 -0
  29. package/src/modes/components/status-line.ts +109 -5
  30. package/src/modes/controllers/command-controller.ts +12 -2
  31. package/src/modes/controllers/extension-ui-controller.ts +12 -21
  32. package/src/modes/controllers/mcp-command-controller.ts +577 -14
  33. package/src/modes/controllers/selector-controller.ts +5 -0
  34. package/src/modes/theme/theme.ts +6 -0
  35. package/src/prompts/tools/hashline.md +4 -3
  36. package/src/sdk.ts +115 -3
  37. package/src/session/agent-session.ts +19 -4
  38. package/src/session/session-manager.ts +17 -5
  39. package/src/slash-commands/builtin-registry.ts +10 -0
  40. package/src/task/executor.ts +37 -3
  41. package/src/task/index.ts +37 -5
  42. package/src/task/isolation-backend.ts +72 -0
  43. package/src/task/render.ts +6 -1
  44. package/src/task/types.ts +1 -0
  45. package/src/task/worktree.ts +67 -5
  46. package/src/tools/index.ts +1 -1
  47. package/src/tools/path-utils.ts +2 -1
  48. package/src/tools/read.ts +3 -7
  49. package/src/utils/open.ts +1 -1
@@ -122,17 +122,18 @@ Range — add `end`:
122
122
  {{hlinefull 62 " return null;"}}
123
123
  {{hlinefull 63 " }"}}
124
124
  ```
125
- Target only the inner lines that changeleave unchanged boundaries out of the range.
125
+ Include the closing `}` in the replaced range stopping one line short orphans the brace or duplicates it.
126
126
  ```
127
127
  {
128
128
  path: "…",
129
129
  edits: [{
130
130
  op: "replace",
131
131
  pos: {{hlinejsonref 61 " console.error(err);"}},
132
- end: {{hlinejsonref 62 " return null;"}},
132
+ end: {{hlinejsonref 63 " }"}},
133
133
  lines: [
134
134
  " if (isEnoent(err)) return null;",
135
- " throw err;"
135
+ " throw err;",
136
+ " }"
136
137
  ]
137
138
  }]
138
139
  }
package/src/sdk.ts CHANGED
@@ -29,6 +29,7 @@ import { initializeWithSettings } from "./discovery";
29
29
  import { TtsrManager } from "./export/ttsr";
30
30
  import {
31
31
  type CustomCommandsLoadResult,
32
+ type LoadedCustomCommand,
32
33
  loadCustomCommands as loadCustomCommandsInternal,
33
34
  } from "./extensibility/custom-commands";
34
35
  import { discoverAndLoadCustomTools } from "./extensibility/custom-tools";
@@ -55,6 +56,7 @@ import {
55
56
  InternalUrlRouter,
56
57
  JobsProtocolHandler,
57
58
  LocalProtocolHandler,
59
+ McpProtocolHandler,
58
60
  MemoryProtocolHandler,
59
61
  PiProtocolHandler,
60
62
  RuleProtocolHandler,
@@ -516,6 +518,54 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
516
518
 
517
519
  // Factory
518
520
 
521
+ /**
522
+ * Build LoadedCustomCommand entries for all MCP prompts across connected servers.
523
+ * These are re-created whenever prompts change (setOnPromptsChanged callback).
524
+ */
525
+ function buildMCPPromptCommands(manager: MCPManager): LoadedCustomCommand[] {
526
+ const commands: LoadedCustomCommand[] = [];
527
+ for (const serverName of manager.getConnectedServers()) {
528
+ const prompts = manager.getServerPrompts(serverName);
529
+ if (!prompts?.length) continue;
530
+ for (const prompt of prompts) {
531
+ const commandName = `${serverName}:${prompt.name}`;
532
+ commands.push({
533
+ path: `mcp:${commandName}`,
534
+ resolvedPath: `mcp:${commandName}`,
535
+ source: "bundled",
536
+ command: {
537
+ name: commandName,
538
+ description: prompt.description ?? `MCP prompt from ${serverName}`,
539
+ async execute(args: string[]) {
540
+ const promptArgs: Record<string, string> = {};
541
+ for (const arg of args) {
542
+ const eqIdx = arg.indexOf("=");
543
+ if (eqIdx > 0) {
544
+ promptArgs[arg.slice(0, eqIdx)] = arg.slice(eqIdx + 1);
545
+ }
546
+ }
547
+ const result = await manager.executePrompt(serverName, prompt.name, promptArgs);
548
+ if (!result) return "";
549
+ const parts: string[] = [];
550
+ for (const msg of result.messages) {
551
+ const contentItems = Array.isArray(msg.content) ? msg.content : [msg.content];
552
+ for (const item of contentItems) {
553
+ if (item.type === "text") {
554
+ parts.push(item.text);
555
+ } else if (item.type === "resource") {
556
+ const resource = item.resource;
557
+ if (resource.text) parts.push(resource.text);
558
+ }
559
+ }
560
+ }
561
+ return parts.join("\n\n");
562
+ },
563
+ },
564
+ });
565
+ }
566
+ }
567
+ return commands;
568
+ }
519
569
  /**
520
570
  * Create an AgentSession with the specified options.
521
571
  *
@@ -823,7 +873,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
823
873
  pendingActionStore,
824
874
  };
825
875
 
826
- // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, local://)
876
+ // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, local://)
827
877
  const internalRouter = new InternalUrlRouter();
828
878
  const getArtifactsDir = () => sessionManager.getArtifactsDir();
829
879
  internalRouter.register(new AgentProtocolHandler({ getArtifactsDir }));
@@ -851,6 +901,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
851
901
  );
852
902
  internalRouter.register(new PiProtocolHandler());
853
903
  internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager }));
904
+ internalRouter.register(new McpProtocolHandler({ getMcpManager: () => mcpManager }));
854
905
  toolSession.internalRouter = internalRouter;
855
906
  toolSession.getArtifactsDir = getArtifactsDir;
856
907
  toolSession.agentOutputManager = new AgentOutputManager(
@@ -885,6 +936,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
885
936
  mcpManager = mcpResult.manager;
886
937
  toolSession.mcpManager = mcpManager;
887
938
 
939
+ if (settings.get("mcp.notifications")) {
940
+ mcpManager.setNotificationsEnabled(true);
941
+ }
888
942
  // If we extracted Exa API keys from MCP configs and EXA_API_KEY isn't set, use the first one
889
943
  if (mcpResult.exaApiKeys.length > 0 && !$env.EXA_API_KEY) {
890
944
  Bun.env.EXA_API_KEY = mcpResult.exaApiKeys[0];
@@ -1139,6 +1193,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1139
1193
  const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
1140
1194
  toolContextStore.setToolNames(toolNames);
1141
1195
  const memoryInstructions = await buildMemoryToolDeveloperInstructions(agentDir, settings);
1196
+
1197
+ // Build combined append prompt: memory instructions + MCP server instructions
1198
+ const serverInstructions = mcpManager?.getServerInstructions();
1199
+ let appendPrompt: string | undefined = memoryInstructions ?? undefined;
1200
+ if (serverInstructions && serverInstructions.size > 0) {
1201
+ const MAX_INSTRUCTIONS_LENGTH = 4000;
1202
+ const parts: string[] = [];
1203
+ if (appendPrompt) parts.push(appendPrompt);
1204
+ parts.push(
1205
+ "## MCP Server Instructions\n\nThe following instructions are provided by connected MCP servers. They are server-controlled and may not be verified.",
1206
+ );
1207
+ for (const [srvName, srvInstructions] of serverInstructions) {
1208
+ const truncated =
1209
+ srvInstructions.length > MAX_INSTRUCTIONS_LENGTH
1210
+ ? `${srvInstructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}\n[truncated]`
1211
+ : srvInstructions;
1212
+ parts.push(`### ${srvName}\n${truncated}`);
1213
+ }
1214
+ appendPrompt = parts.join("\n\n");
1215
+ }
1142
1216
  const defaultPrompt = await buildSystemPromptInternal({
1143
1217
  cwd,
1144
1218
  skills,
@@ -1147,7 +1221,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1147
1221
  toolNames,
1148
1222
  rules: rulebookRules,
1149
1223
  skillsSettings: settings.getGroup("skills") as SkillsSettings,
1150
- appendSystemPrompt: memoryInstructions,
1224
+ appendSystemPrompt: appendPrompt,
1151
1225
  repeatToolDescriptions,
1152
1226
  eagerTasks,
1153
1227
  intentField,
@@ -1166,7 +1240,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1166
1240
  rules: rulebookRules,
1167
1241
  skillsSettings: settings.getGroup("skills") as SkillsSettings,
1168
1242
  customPrompt: options.systemPrompt,
1169
- appendSystemPrompt: memoryInstructions,
1243
+ appendSystemPrompt: appendPrompt,
1170
1244
  repeatToolDescriptions,
1171
1245
  eagerTasks,
1172
1246
  intentField,
@@ -1410,6 +1484,44 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1410
1484
  taskDepth,
1411
1485
  });
1412
1486
 
1487
+ // Wire MCP manager callbacks to session for reactive tool updates
1488
+ if (mcpManager) {
1489
+ mcpManager.setOnToolsChanged(tools => {
1490
+ void session.refreshMCPTools(tools);
1491
+ });
1492
+ // Wire prompt refresh → rebuild MCP prompt slash commands
1493
+ mcpManager.setOnPromptsChanged(serverName => {
1494
+ const promptCommands = buildMCPPromptCommands(mcpManager);
1495
+ session.setMCPPromptCommands(promptCommands);
1496
+ logger.debug("MCP prompt commands refreshed", { path: `mcp:${serverName}` });
1497
+ });
1498
+ const notificationDebounceTimers = new Map<string, Timer>();
1499
+ const clearDebounceTimers = () => {
1500
+ for (const timer of notificationDebounceTimers.values()) clearTimeout(timer);
1501
+ notificationDebounceTimers.clear();
1502
+ };
1503
+ postmortem.register("mcp-notification-cleanup", clearDebounceTimers);
1504
+ mcpManager.setOnResourcesChanged((serverName, uri) => {
1505
+ logger.debug("MCP resources changed", { path: `mcp:${serverName}`, uri });
1506
+ if (!settings.get("mcp.notifications")) return;
1507
+ const debounceMs = (settings.get("mcp.notificationDebounceMs") as number) ?? 500;
1508
+ const key = `${serverName}:${uri}`;
1509
+ const existing = notificationDebounceTimers.get(key);
1510
+ if (existing) clearTimeout(existing);
1511
+ notificationDebounceTimers.set(
1512
+ key,
1513
+ setTimeout(() => {
1514
+ notificationDebounceTimers.delete(key);
1515
+ // Re-check: user may have disabled notifications during the debounce window
1516
+ if (!settings.get("mcp.notifications")) return;
1517
+ void session.followUp(
1518
+ `[MCP notification] Server "${serverName}" reports resource \`${uri}\` was updated. Use read(path="mcp://${uri}") to inspect if relevant.`,
1519
+ );
1520
+ }, debounceMs),
1521
+ );
1522
+ });
1523
+ }
1524
+
1413
1525
  return {
1414
1526
  session,
1415
1527
  extensionsResult,
@@ -237,6 +237,7 @@ export interface SessionStats {
237
237
  cacheWrite: number;
238
238
  total: number;
239
239
  };
240
+ premiumRequests: number;
240
241
  cost: number;
241
242
  }
242
243
 
@@ -352,6 +353,8 @@ export class AgentSession {
352
353
 
353
354
  // Custom commands (TypeScript slash commands)
354
355
  #customCommands: LoadedCustomCommand[] = [];
356
+ /** MCP prompt commands (updated dynamically when prompts are loaded) */
357
+ #mcpPromptCommands: LoadedCustomCommand[] = [];
355
358
 
356
359
  #skillsSettings: Required<SkillsSettings> | undefined;
357
360
 
@@ -1769,9 +1772,15 @@ export class AgentSession {
1769
1772
  this.#slashCommands = [...slashCommands];
1770
1773
  }
1771
1774
 
1772
- /** Custom commands (TypeScript slash commands) */
1775
+ /** Custom commands (TypeScript slash commands and MCP prompts) */
1773
1776
  get customCommands(): ReadonlyArray<LoadedCustomCommand> {
1774
- return this.#customCommands;
1777
+ if (this.#mcpPromptCommands.length === 0) return this.#customCommands;
1778
+ return [...this.#customCommands, ...this.#mcpPromptCommands];
1779
+ }
1780
+
1781
+ /** Update the MCP prompt commands list. Called when server prompts are (re)loaded. */
1782
+ setMCPPromptCommands(commands: LoadedCustomCommand[]): void {
1783
+ this.#mcpPromptCommands = commands;
1775
1784
  }
1776
1785
 
1777
1786
  // =========================================================================
@@ -2173,7 +2182,7 @@ export class AgentSession {
2173
2182
  * If the command returns void, returns empty string to indicate it was handled.
2174
2183
  */
2175
2184
  async #tryExecuteCustomCommand(text: string): Promise<string | null> {
2176
- if (this.#customCommands.length === 0) return null;
2185
+ if (this.#customCommands.length === 0 && this.#mcpPromptCommands.length === 0) return null;
2177
2186
 
2178
2187
  // Parse command name and args
2179
2188
  const spaceIndex = text.indexOf(" ");
@@ -2181,7 +2190,9 @@ export class AgentSession {
2181
2190
  const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
2182
2191
 
2183
2192
  // Find matching command
2184
- const loaded = this.#customCommands.find(c => c.command.name === commandName);
2193
+ const loaded =
2194
+ this.#customCommands.find(c => c.command.name === commandName) ??
2195
+ this.#mcpPromptCommands.find(c => c.command.name === commandName);
2185
2196
  if (!loaded) return null;
2186
2197
 
2187
2198
  // Get command context from extension runner (includes session control methods)
@@ -4781,6 +4792,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4781
4792
  let totalCacheWrite = 0;
4782
4793
  let totalCost = 0;
4783
4794
 
4795
+ let totalPremiumRequests = 0;
4784
4796
  const getTaskToolUsage = (details: unknown): Usage | undefined => {
4785
4797
  if (!details || typeof details !== "object") return undefined;
4786
4798
  const record = details as Record<string, unknown>;
@@ -4797,6 +4809,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4797
4809
  totalOutput += assistantMsg.usage.output;
4798
4810
  totalCacheRead += assistantMsg.usage.cacheRead;
4799
4811
  totalCacheWrite += assistantMsg.usage.cacheWrite;
4812
+ totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
4800
4813
  totalCost += assistantMsg.usage.cost.total;
4801
4814
  }
4802
4815
 
@@ -4807,6 +4820,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4807
4820
  totalOutput += usage.output;
4808
4821
  totalCacheRead += usage.cacheRead;
4809
4822
  totalCacheWrite += usage.cacheWrite;
4823
+ totalPremiumRequests += usage.premiumRequests ?? 0;
4810
4824
  totalCost += usage.cost.total;
4811
4825
  }
4812
4826
  }
@@ -4828,6 +4842,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4828
4842
  total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
4829
4843
  },
4830
4844
  cost: totalCost,
4845
+ premiumRequests: totalPremiumRequests,
4831
4846
  };
4832
4847
  }
4833
4848
 
@@ -1049,6 +1049,7 @@ export interface UsageStatistics {
1049
1049
  output: number;
1050
1050
  cacheRead: number;
1051
1051
  cacheWrite: number;
1052
+ premiumRequests: number;
1052
1053
  cost: number;
1053
1054
  }
1054
1055
 
@@ -1144,9 +1145,16 @@ export class SessionManager {
1144
1145
  #fileEntries: FileEntry[] = [];
1145
1146
  #byId: Map<string, SessionEntry> = new Map();
1146
1147
  #labelsById: Map<string, string> = new Map();
1147
- #leafId: string | null = null;
1148
- #usageStatistics: UsageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1149
- #persistWriter: NdjsonFileWriter | undefined;
1148
+ #leafId = null as string | null;
1149
+ #usageStatistics = {
1150
+ input: 0,
1151
+ output: 0,
1152
+ cacheRead: 0,
1153
+ cacheWrite: 0,
1154
+ premiumRequests: 0,
1155
+ cost: 0,
1156
+ } satisfies UsageStatistics;
1157
+ #persistWriter = undefined as NdjsonFileWriter | undefined;
1150
1158
  #persistWriterPath: string | undefined;
1151
1159
  #persistChain: Promise<void> = Promise.resolve();
1152
1160
  #persistError: Error | undefined;
@@ -1373,7 +1381,7 @@ export class SessionManager {
1373
1381
  this.#labelsById.clear();
1374
1382
  this.#leafId = null;
1375
1383
  this.#flushed = false;
1376
- this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1384
+ this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
1377
1385
 
1378
1386
  if (this.persist) {
1379
1387
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -1387,7 +1395,7 @@ export class SessionManager {
1387
1395
  this.#byId.clear();
1388
1396
  this.#labelsById.clear();
1389
1397
  this.#leafId = null;
1390
- this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1398
+ this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
1391
1399
  for (const entry of this.#fileEntries) {
1392
1400
  if (entry.type === "session") continue;
1393
1401
  this.#byId.set(entry.id, entry);
@@ -1405,6 +1413,7 @@ export class SessionManager {
1405
1413
  this.#usageStatistics.output += usage.output;
1406
1414
  this.#usageStatistics.cacheRead += usage.cacheRead;
1407
1415
  this.#usageStatistics.cacheWrite += usage.cacheWrite;
1416
+ this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
1408
1417
  this.#usageStatistics.cost += usage.cost.total;
1409
1418
  }
1410
1419
 
@@ -1415,6 +1424,7 @@ export class SessionManager {
1415
1424
  this.#usageStatistics.output += usage.output;
1416
1425
  this.#usageStatistics.cacheRead += usage.cacheRead;
1417
1426
  this.#usageStatistics.cacheWrite += usage.cacheWrite;
1427
+ this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
1418
1428
  this.#usageStatistics.cost += usage.cost.total;
1419
1429
  }
1420
1430
  }
@@ -1679,6 +1689,7 @@ export class SessionManager {
1679
1689
  this.#usageStatistics.output += usage.output;
1680
1690
  this.#usageStatistics.cacheRead += usage.cacheRead;
1681
1691
  this.#usageStatistics.cacheWrite += usage.cacheWrite;
1692
+ this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
1682
1693
  this.#usageStatistics.cost += usage.cost.total;
1683
1694
  }
1684
1695
 
@@ -1689,6 +1700,7 @@ export class SessionManager {
1689
1700
  this.#usageStatistics.output += usage.output;
1690
1701
  this.#usageStatistics.cacheRead += usage.cacheRead;
1691
1702
  this.#usageStatistics.cacheWrite += usage.cacheWrite;
1703
+ this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
1692
1704
  this.#usageStatistics.cost += usage.cost.total;
1693
1705
  }
1694
1706
  }
@@ -315,7 +315,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
315
315
  { name: "unauth", description: "Remove OAuth auth from a server", usage: "<name>" },
316
316
  { name: "enable", description: "Enable an MCP server", usage: "<name>" },
317
317
  { name: "disable", description: "Disable an MCP server", usage: "<name>" },
318
+ {
319
+ name: "smithery-search",
320
+ description: "Search Smithery registry and deploy an MCP server",
321
+ usage: "<keyword> [--scope project|user] [--limit <1-100>] [--semantic]",
322
+ },
323
+ { name: "smithery-login", description: "Login to Smithery and cache API key" },
324
+ { name: "smithery-logout", description: "Remove cached Smithery API key" },
318
325
  { name: "reload", description: "Force reload MCP runtime tools" },
326
+ { name: "resources", description: "List available resources from connected servers" },
327
+ { name: "prompts", description: "List available prompts from connected servers" },
328
+ { name: "notifications", description: "Show notification capabilities and subscriptions" },
319
329
  { name: "help", description: "Show help message" },
320
330
  ],
321
331
  allowArgs: true,
@@ -499,12 +499,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
499
499
  description: options.description,
500
500
  exitCode: 1,
501
501
  output: "",
502
- stderr: "Aborted before start",
502
+ stderr: "Cancelled before start",
503
503
  truncated: false,
504
504
  durationMs: 0,
505
505
  tokens: 0,
506
506
  modelOverride,
507
- error: "Aborted",
507
+ error: "Cancelled before start",
508
+ aborted: true,
509
+ abortReason: "Cancelled before start",
508
510
  };
509
511
  }
510
512
 
@@ -612,6 +614,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
612
614
  signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
613
615
  }
614
616
 
617
+ const resolveSignalAbortReason = (): string => {
618
+ const reason = signal?.reason;
619
+ if (reason instanceof Error) {
620
+ const message = reason.message.trim();
621
+ if (message.length > 0) return message;
622
+ } else if (typeof reason === "string") {
623
+ const message = reason.trim();
624
+ if (message.length > 0) return message;
625
+ }
626
+ return "Cancelled by caller";
627
+ };
615
628
  const PROGRESS_COALESCE_MS = 150;
616
629
  let lastProgressEmitMs = 0;
617
630
  let progressTimeoutId: NodeJS.Timeout | null = null;
@@ -898,16 +911,20 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
898
911
  exitCode: number;
899
912
  error?: string;
900
913
  aborted?: boolean;
914
+ abortReason?: string;
901
915
  durationMs: number;
902
916
  }> => {
903
917
  const sessionAbortController = new AbortController();
904
918
  let exitCode = 0;
905
919
  let error: string | undefined;
906
920
  let aborted = false;
907
-
921
+ let abortReasonText: string | undefined;
908
922
  const checkAbort = () => {
909
923
  if (abortSignal.aborted) {
910
924
  aborted = abortReason === "signal" || abortReason === undefined;
925
+ if (aborted) {
926
+ abortReasonText ??= resolveSignalAbortReason();
927
+ }
911
928
  exitCode = 1;
912
929
  throw new ToolAbortError();
913
930
  }
@@ -1096,6 +1113,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1096
1113
  if (!submitResultCalled && !abortSignal.aborted) {
1097
1114
  aborted = true;
1098
1115
  exitCode = 1;
1116
+ abortReasonText ??= SUBAGENT_WARNING_MISSING_SUBMIT_RESULT;
1099
1117
  error ??= SUBAGENT_WARNING_MISSING_SUBMIT_RESULT;
1100
1118
  }
1101
1119
 
@@ -1103,6 +1121,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1103
1121
  if (lastAssistant) {
1104
1122
  if (lastAssistant.stopReason === "aborted") {
1105
1123
  aborted = abortReason === "signal" || abortReason === undefined;
1124
+ if (aborted) {
1125
+ abortReasonText ??= resolveSignalAbortReason();
1126
+ }
1106
1127
  exitCode = 1;
1107
1128
  } else if (lastAssistant.stopReason === "error") {
1108
1129
  exitCode = 1;
@@ -1117,6 +1138,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1117
1138
  } finally {
1118
1139
  if (abortSignal.aborted) {
1119
1140
  aborted = abortReason === "signal" || abortReason === undefined;
1141
+ if (aborted) {
1142
+ abortReasonText ??= resolveSignalAbortReason();
1143
+ }
1120
1144
  if (exitCode === 0) exitCode = 1;
1121
1145
  }
1122
1146
  sessionAbortController.abort();
@@ -1143,6 +1167,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1143
1167
  exitCode,
1144
1168
  error,
1145
1169
  aborted,
1170
+ abortReason: aborted ? abortReasonText : undefined,
1146
1171
  durationMs: Date.now() - startTime,
1147
1172
  };
1148
1173
  };
@@ -1178,6 +1203,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1178
1203
  rawOutput = finalized.rawOutput;
1179
1204
  exitCode = finalized.exitCode;
1180
1205
  stderr = finalized.stderr;
1206
+ const lastSubmitResult = submitResultItems?.[submitResultItems.length - 1];
1207
+ const submitResultAbortReason =
1208
+ lastSubmitResult?.status === "aborted" ? lastSubmitResult.error || "Subagent aborted task" : undefined;
1181
1209
  const { abortedViaSubmitResult, hasSubmitResult } = finalized;
1182
1210
  const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
1183
1211
  maxBytes: MAX_OUTPUT_BYTES,
@@ -1203,6 +1231,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1203
1231
 
1204
1232
  // Update final progress
1205
1233
  const wasAborted = abortedViaSubmitResult || (!hasSubmitResult && (done.aborted || signal?.aborted || false));
1234
+ const finalAbortReason = wasAborted
1235
+ ? abortedViaSubmitResult
1236
+ ? submitResultAbortReason
1237
+ : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task"))
1238
+ : undefined;
1206
1239
  progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
1207
1240
  scheduleProgress(true);
1208
1241
 
@@ -1223,6 +1256,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1223
1256
  modelOverride,
1224
1257
  error: exitCode !== 0 && stderr ? stderr : undefined,
1225
1258
  aborted: wasAborted,
1259
+ abortReason: finalAbortReason,
1226
1260
  usage: hasUsage ? accumulatedUsage : undefined,
1227
1261
  outputPath,
1228
1262
  extractedToolData: progress.extractedToolData,
package/src/task/index.ts CHANGED
@@ -32,6 +32,7 @@ import "../tools/review";
32
32
  import { generateCommitMessage } from "../utils/commit-message-generator";
33
33
  import { discoverAgents, getAgent } from "./discovery";
34
34
  import { runSubprocess } from "./executor";
35
+ import { resolveIsolationBackendForTaskExecution } from "./isolation-backend";
35
36
  import { AgentOutputManager } from "./output-manager";
36
37
  import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
37
38
  import { renderCall, renderResult } from "./render";
@@ -52,10 +53,12 @@ import {
52
53
  captureBaseline,
53
54
  captureDeltaPatch,
54
55
  cleanupFuseOverlay,
56
+ cleanupProjfsOverlay,
55
57
  cleanupTaskBranches,
56
58
  cleanupWorktree,
57
59
  commitToBranch,
58
60
  ensureFuseOverlay,
61
+ ensureProjfsOverlay,
59
62
  ensureWorktree,
60
63
  getRepoRoot,
61
64
  mergeTaskBranches,
@@ -442,7 +445,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
442
445
  content: [
443
446
  {
444
447
  type: "text",
445
- text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree' or 'fuse-overlay'.",
448
+ text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree', 'fuse-overlay', or 'fuse-projfs'.",
446
449
  },
447
450
  ],
448
451
  details: {
@@ -605,6 +608,29 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
605
608
  }
606
609
  }
607
610
 
611
+ let effectiveIsolationMode = isolationMode;
612
+ let isolationBackendWarning = "";
613
+ try {
614
+ const resolvedIsolation = await resolveIsolationBackendForTaskExecution(isolationMode, isIsolated, repoRoot);
615
+ effectiveIsolationMode = resolvedIsolation.effectiveIsolationMode;
616
+ isolationBackendWarning = resolvedIsolation.warning;
617
+ } catch (err) {
618
+ const message = err instanceof Error ? err.message : String(err);
619
+ return {
620
+ content: [
621
+ {
622
+ type: "text",
623
+ text: message,
624
+ },
625
+ ],
626
+ details: {
627
+ projectAgentsDir,
628
+ results: [],
629
+ totalDurationMs: Date.now() - startTime,
630
+ },
631
+ };
632
+ }
633
+
608
634
  // Derive artifacts directory
609
635
  const sessionFile = this.session.getSessionFile();
610
636
  const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
@@ -761,8 +787,10 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
761
787
  }
762
788
  const taskBaseline = structuredClone(baseline);
763
789
 
764
- if (isolationMode === "fuse-overlay") {
790
+ if (effectiveIsolationMode === "fuse-overlay") {
765
791
  isolationDir = await ensureFuseOverlay(repoRoot, task.id);
792
+ } else if (effectiveIsolationMode === "fuse-projfs") {
793
+ isolationDir = await ensureProjfsOverlay(repoRoot, task.id);
766
794
  } else {
767
795
  isolationDir = await ensureWorktree(repoRoot, task.id);
768
796
  await applyBaseline(isolationDir, taskBaseline);
@@ -871,8 +899,10 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
871
899
  };
872
900
  } finally {
873
901
  if (isolationDir) {
874
- if (isolationMode === "fuse-overlay") {
902
+ if (effectiveIsolationMode === "fuse-overlay") {
875
903
  await cleanupFuseOverlay(isolationDir);
904
+ } else if (effectiveIsolationMode === "fuse-projfs") {
905
+ await cleanupProjfsOverlay(isolationDir);
876
906
  } else {
877
907
  await cleanupWorktree(isolationDir);
878
908
  }
@@ -908,8 +938,9 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
908
938
  durationMs: 0,
909
939
  tokens: 0,
910
940
  modelOverride,
911
- error: "Skipped",
941
+ error: "Cancelled before start",
912
942
  aborted: true,
943
+ abortReason: "Cancelled before start",
913
944
  };
914
945
  });
915
946
 
@@ -1108,6 +1139,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
1108
1139
  });
1109
1140
 
1110
1141
  const outputIds = results.filter(r => !r.aborted || r.output.trim()).map(r => `agent://${r.id}`);
1142
+ const backendSummaryPrefix = isolationBackendWarning ? `\n\n${isolationBackendWarning}` : "";
1111
1143
  const summary = renderPromptTemplate(taskSummaryTemplate, {
1112
1144
  successCount,
1113
1145
  totalCount: results.length,
@@ -1117,7 +1149,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
1117
1149
  summaries,
1118
1150
  outputIds,
1119
1151
  agentName,
1120
- mergeSummary,
1152
+ mergeSummary: `${backendSummaryPrefix}${mergeSummary}`,
1121
1153
  });
1122
1154
 
1123
1155
  // Cleanup temp directory if used