@oh-my-pi/pi-coding-agent 17.0.2 → 17.0.4

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 (80) hide show
  1. package/CHANGELOG.md +80 -20
  2. package/dist/cli.js +4087 -4108
  3. package/dist/types/config/settings-schema.d.ts +7 -3
  4. package/dist/types/eval/agent-bridge.d.ts +7 -19
  5. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +15 -1
  6. package/dist/types/lsp/client.d.ts +2 -0
  7. package/dist/types/lsp/types.d.ts +3 -0
  8. package/dist/types/mcp/transports/stdio.d.ts +29 -0
  9. package/dist/types/mnemopi/state.d.ts +32 -9
  10. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  11. package/dist/types/registry/persisted-agents.d.ts +3 -0
  12. package/dist/types/sdk.d.ts +14 -3
  13. package/dist/types/session/session-entries.d.ts +6 -1
  14. package/dist/types/session/session-manager.d.ts +5 -0
  15. package/dist/types/task/executor.d.ts +26 -1
  16. package/dist/types/task/index.d.ts +1 -1
  17. package/dist/types/task/parallel.d.ts +14 -0
  18. package/dist/types/task/structured-subagent.d.ts +111 -0
  19. package/dist/types/task/types.d.ts +51 -0
  20. package/dist/types/tools/fetch.d.ts +4 -5
  21. package/dist/types/tools/hub/messaging.d.ts +5 -1
  22. package/dist/types/tools/index.d.ts +16 -1
  23. package/dist/types/tools/todo.d.ts +31 -0
  24. package/dist/types/tui/tree-list.d.ts +7 -0
  25. package/dist/types/utils/markit.d.ts +11 -0
  26. package/package.json +12 -12
  27. package/src/cli/file-processor.ts +1 -2
  28. package/src/config/settings-schema.ts +4 -3
  29. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  30. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  31. package/src/eval/agent-bridge.ts +104 -477
  32. package/src/eval/jl/prelude.jl +7 -6
  33. package/src/eval/js/shared/prelude.txt +5 -4
  34. package/src/eval/py/prelude.py +11 -39
  35. package/src/eval/rb/prelude.rb +5 -6
  36. package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
  37. package/src/lsp/client.ts +57 -0
  38. package/src/lsp/index.ts +62 -6
  39. package/src/lsp/types.ts +3 -0
  40. package/src/mcp/oauth-flow.ts +20 -0
  41. package/src/mcp/transports/stdio.test.ts +269 -1
  42. package/src/mcp/transports/stdio.ts +152 -1
  43. package/src/mnemopi/backend.ts +1 -1
  44. package/src/mnemopi/embed-worker.ts +8 -7
  45. package/src/mnemopi/state.ts +45 -18
  46. package/src/modes/components/agent-hub.ts +1 -72
  47. package/src/modes/components/bash-execution.ts +7 -3
  48. package/src/modes/components/eval-execution.ts +3 -1
  49. package/src/modes/components/transcript-container.ts +13 -7
  50. package/src/modes/interactive-mode.ts +30 -8
  51. package/src/prompts/system/system-prompt.md +1 -1
  52. package/src/prompts/tools/eval.md +5 -2
  53. package/src/prompts/tools/read.md +1 -1
  54. package/src/prompts/tools/task.md +12 -8
  55. package/src/prompts/tools/write.md +1 -1
  56. package/src/registry/persisted-agents.ts +74 -0
  57. package/src/sdk.ts +129 -87
  58. package/src/session/agent-session.ts +75 -21
  59. package/src/session/session-entries.ts +6 -1
  60. package/src/session/session-loader.ts +31 -3
  61. package/src/session/session-manager.ts +9 -0
  62. package/src/session/streaming-output.ts +41 -1
  63. package/src/stt/recorder.ts +68 -55
  64. package/src/system-prompt.ts +7 -2
  65. package/src/task/executor.ts +99 -21
  66. package/src/task/index.ts +258 -429
  67. package/src/task/parallel.ts +43 -0
  68. package/src/task/persisted-revive.ts +19 -7
  69. package/src/task/structured-subagent.ts +642 -0
  70. package/src/task/types.ts +61 -0
  71. package/src/tools/__tests__/eval-description.test.ts +1 -1
  72. package/src/tools/fetch.ts +28 -105
  73. package/src/tools/hub/messaging.ts +16 -3
  74. package/src/tools/index.ts +47 -14
  75. package/src/tools/path-utils.ts +1 -0
  76. package/src/tools/read.ts +14 -26
  77. package/src/tools/todo.ts +126 -13
  78. package/src/tui/tree-list.ts +39 -0
  79. package/src/utils/markit.ts +12 -0
  80. package/src/utils/zip.ts +16 -1
package/src/sdk.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  } from "./advisor";
35
35
  import { type AsyncJob, AsyncJobManager } from "./async";
36
36
  import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
37
+ import { createAutoresearchExtension } from "./autoresearch";
37
38
  import { loadCapability } from "./capability";
38
39
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
39
40
  import { bucketRules } from "./capability/rule-buckets";
@@ -145,6 +146,7 @@ import {
145
146
  } from "./system-prompt";
146
147
  import { AgentOutputManager } from "./task/output-manager";
147
148
  import { wrapStreamFnWithProviderConcurrency } from "./task/provider-concurrency";
149
+ import type { StructuredSubagentSchemaMode } from "./task/types";
148
150
  import {
149
151
  AUTO_THINKING,
150
152
  type ConfiguredThinkingLevel,
@@ -482,20 +484,30 @@ export interface CreateAgentSessionOptions {
482
484
  /** File-based slash commands. Default: discovered from commands/ directories */
483
485
  slashCommands?: FileSlashCommand[];
484
486
 
485
- /** Enable MCP server discovery from .mcp.json files. Default: true */
487
+ /**
488
+ * Enable MCP capabilities. `false` skips MCP discovery and ignores
489
+ * `mcpManager`, preventing process-global or inherited MCP access. Default:
490
+ * true.
491
+ */
486
492
  enableMCP?: boolean;
487
- /** Existing MCP manager to reuse (skips discovery, propagates to toolSession). */
493
+ /** Existing MCP manager to reuse when MCP is enabled (skips discovery, propagates to toolSession). */
488
494
  mcpManager?: MCPManager;
489
495
 
490
496
  /** Enable LSP integration (tool, formatting, diagnostics, warmup). Default: true */
491
497
  enableLsp?: boolean;
498
+ /** Whether this invocation may expose IRC. `false` removes it even for subagents. */
499
+ enableIrc?: boolean;
492
500
  /** Skip subprocess-kernel availability checks and prelude warmup */
493
501
  skipPythonPreflight?: boolean;
494
502
  /** Tool names explicitly requested (enables disabled-by-default tools) */
495
503
  toolNames?: string[];
504
+ /** Limit the session to explicitly supplied tool names, without discovered extras. */
505
+ restrictToolNames?: boolean;
496
506
 
497
- /** Output schema for structured completion (subagents) */
507
+ /** Output schema for structured completion (subagents). */
498
508
  outputSchema?: unknown;
509
+ /** Enforcement policy for {@link outputSchema}; defaults to legacy permissive behavior. */
510
+ outputSchemaMode?: StructuredSubagentSchemaMode;
499
511
  /** Whether to include the yield tool by default */
500
512
  requireYieldTool?: boolean;
501
513
  /** Task recursion depth (for subagent sessions). Default: 0 */
@@ -1544,7 +1556,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1544
1556
  let session!: AgentSession;
1545
1557
  let hasSession = false;
1546
1558
  let hasRegistered = false;
1547
- const enableLsp = options.enableLsp ?? true;
1559
+ const restrictToolNames = options.restrictToolNames === true;
1560
+ const enableLsp = !restrictToolNames && (options.enableLsp ?? true);
1548
1561
  const asyncMaxJobs = Math.min(100, Math.max(1, settings.get("async.maxJobs") ?? 100));
1549
1562
  const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
1550
1563
  const ASYNC_PREVIEW_MAX_CHARS = 4_000;
@@ -1641,9 +1654,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1641
1654
  setActiveToolNames,
1642
1655
  hasUI: options.hasUI ?? false,
1643
1656
  enableLsp,
1657
+ enableIrc: restrictToolNames ? false : options.enableIrc,
1658
+ restrictToolNames,
1644
1659
  get hasEditTool() {
1645
1660
  const requestedToolNames = options.toolNames ? normalizeToolNames(options.toolNames) : undefined;
1646
- return !requestedToolNames || requestedToolNames.includes("edit");
1661
+ return restrictToolNames
1662
+ ? requestedToolNames?.includes("edit") === true
1663
+ : !requestedToolNames || requestedToolNames.includes("edit");
1647
1664
  },
1648
1665
  skipPythonPreflight: options.skipPythonPreflight,
1649
1666
  contextFiles,
@@ -1655,6 +1672,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1655
1672
  rules: allRules,
1656
1673
  eventBus,
1657
1674
  outputSchema: options.outputSchema,
1675
+ outputSchemaMode: options.outputSchemaMode,
1658
1676
  requireYieldTool: options.requireYieldTool,
1659
1677
  prewalkArmed: options.prewalk !== undefined,
1660
1678
  taskDepth: options.taskDepth ?? 0,
@@ -1771,10 +1789,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1771
1789
  // Create built-in tools (already wrapped with meta notice formatting)
1772
1790
  const builtinTools = await logger.time("createAllTools", createTools, toolSession, options.toolNames);
1773
1791
 
1774
- // Discover MCP tools from .mcp.json files
1775
- let mcpManager: MCPManager | undefined = options.mcpManager;
1792
+ // Restricted sessions cannot inherit or discover MCP capabilities.
1793
+ const enableMCP = !restrictToolNames && (options.enableMCP ?? true);
1794
+ let mcpManager: MCPManager | undefined = enableMCP ? options.mcpManager : undefined;
1776
1795
  toolSession.mcpManager = mcpManager;
1777
- const enableMCP = options.enableMCP ?? true;
1796
+ toolSession.enableMCP = enableMCP;
1778
1797
  const deferMCPDiscoveryForUI = enableMCP && !mcpManager && options.hasUI === true;
1779
1798
  const customTools: CustomTool[] = [];
1780
1799
  let startDeferredMCPDiscovery: ((liveSession: AgentSession) => void) | undefined;
@@ -1860,58 +1879,62 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1860
1879
  // to mirror the AsyncJobManager ownership rule.
1861
1880
  if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
1862
1881
 
1863
- // Add image tools when generation is enabled and either no explicit tool
1864
- // whitelist was given or it names `generate_image`. Image gen is a
1865
- // discoverable custom tool: once it enters the registry the common
1866
- // partition presents it under xd:// (or routes it to BM25 discovery), so no
1867
- // source-specific force-activation is needed only this eligibility gate.
1868
- const imageGenRequested = !options.toolNames || options.toolNames.includes("generate_image");
1869
- if (settings.get("generate_image.enabled") && imageGenRequested) {
1870
- const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
1871
- if (imageGenTools.length > 0) {
1872
- customTools.push(...(imageGenTools as unknown as CustomTool[]));
1882
+ const builtInToolNames = builtinTools.map(t => t.name);
1883
+ let customToolPaths: ToolPathWithSource[] = [];
1884
+ const inlineExtensions: ExtensionFactory[] = [];
1885
+ if (!restrictToolNames) {
1886
+ // Add image tools when generation is enabled and either no explicit tool
1887
+ // whitelist was given or it names `generate_image`. Unlike built-in tools
1888
+ // (filtered in `createTools`), custom tools are force-activated via
1889
+ // `alwaysInclude` below, so an explicit `--no-tools`/whitelist must be
1890
+ // honored here or image-gen would leak past every filter (issue #5305).
1891
+ const imageGenRequested = !options.toolNames || options.toolNames.includes("generate_image");
1892
+ if (settings.get("generate_image.enabled") && imageGenRequested) {
1893
+ const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
1894
+ if (imageGenTools.length > 0) {
1895
+ customTools.push(...(imageGenTools as unknown as CustomTool[]));
1896
+ }
1873
1897
  }
1874
- }
1875
1898
 
1876
- if (settings.get("speechgen.enabled")) {
1877
- customTools.push(ttsTool as unknown as CustomTool);
1878
- }
1899
+ if (settings.get("speechgen.enabled")) {
1900
+ customTools.push(ttsTool as unknown as CustomTool);
1901
+ }
1879
1902
 
1880
- // Add web search tools
1881
- if (options.toolNames?.includes("web_search")) {
1882
- customTools.push(...getSearchTools());
1883
- }
1903
+ // Add web search tools
1904
+ if (options.toolNames?.includes("web_search")) {
1905
+ customTools.push(...getSearchTools());
1906
+ }
1884
1907
 
1885
- // Discover custom tools from `.omp/tools/`, `.claude/tools/`, plugins, etc.
1886
- // Subagents reuse the parent's scan via `preloadedCustomToolPaths` to skip
1887
- // the FS walk, but ALWAYS re-call `loadCustomTools` here so factories bind
1888
- // to THIS session's `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
1889
- // Forwarding the parent's `LoadedCustomTool[]` directly would route tool
1890
- // execution back through the parent — wrong for isolated tasks and for
1891
- // pending-action queueing.
1892
- const builtInToolNames = builtinTools.map(t => t.name);
1893
- const customToolPaths: ToolPathWithSource[] =
1894
- options.preloadedCustomToolPaths ??
1895
- (await logger.time("discoverCustomToolPaths", () => discoverCustomToolPaths([], cwd)));
1896
- const customToolsLoadResult = await logger.time("loadCustomTools", () =>
1897
- loadCustomTools(customToolPaths, cwd, builtInToolNames, action => queueResolveHandler(toolSession, action)),
1898
- );
1899
- for (const { path, error } of customToolsLoadResult.errors) {
1900
- logger.error("Custom tool load failed", { path, error });
1901
- }
1902
- if (customToolsLoadResult.tools.length > 0) {
1903
- customTools.push(...customToolsLoadResult.tools.map(loaded => loaded.tool));
1908
+ // Discover custom tools from `.omp/tools/`, `.claude/tools/`, plugins, etc.
1909
+ // Subagents reuse the parent's scan via `preloadedCustomToolPaths` to skip
1910
+ // the FS walk, but ALWAYS re-call `loadCustomTools` here so factories bind
1911
+ // to THIS session's `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
1912
+ // Forwarding the parent's `LoadedCustomTool[]` directly would route tool
1913
+ // execution back through the parent — wrong for isolated tasks and for
1914
+ // pending-action queueing.
1915
+ customToolPaths =
1916
+ options.preloadedCustomToolPaths ??
1917
+ (await logger.time("discoverCustomToolPaths", () => discoverCustomToolPaths([], cwd)));
1918
+ const customToolsLoadResult = await logger.time("loadCustomTools", () =>
1919
+ loadCustomTools(customToolPaths, cwd, builtInToolNames, action => queueResolveHandler(toolSession, action)),
1920
+ );
1921
+ for (const { path, error } of customToolsLoadResult.errors) {
1922
+ logger.error("Custom tool load failed", { path, error });
1923
+ }
1924
+ if (customToolsLoadResult.tools.length > 0) {
1925
+ customTools.push(...customToolsLoadResult.tools.map(loaded => loaded.tool));
1926
+ }
1927
+
1928
+ inlineExtensions.push(...(options.extensions ?? []));
1929
+ inlineExtensions.push(createAutoresearchExtension);
1930
+ if (customTools.length > 0) {
1931
+ inlineExtensions.push(createCustomToolsExtension(customTools));
1932
+ }
1904
1933
  }
1905
1934
  // Forward the path list (NOT the loaded tools) to subagents so they
1906
1935
  // re-bind under their own `CustomToolAPI` while skipping the FS scan.
1907
1936
  toolSession.customToolPaths = customToolPaths;
1908
1937
 
1909
- const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : [];
1910
- inlineExtensions.push((await import("./autoresearch")).createAutoresearchExtension);
1911
- if (customTools.length > 0) {
1912
- inlineExtensions.push(createCustomToolsExtension(customTools));
1913
- }
1914
-
1915
1938
  // Load extensions. Three paths:
1916
1939
  // 1. `preloadedExtensions` (CLI): caller already loaded — reuse the
1917
1940
  // Extension instances. Shallow-clone `extensions` so the inline
@@ -1926,7 +1949,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1926
1949
  // the flag and pre-resolved the result already reflects that choice.
1927
1950
  let extensionPaths: string[];
1928
1951
  let extensionsResult: LoadExtensionsResult;
1929
- if (options.preloadedExtensions) {
1952
+ if (restrictToolNames) {
1953
+ // Allocate a session runtime without evaluating caller-provided extension
1954
+ // instances, paths, or factories.
1955
+ extensionPaths = [];
1956
+ extensionsResult = await loadExtensions([], cwd, eventBus);
1957
+ } else if (options.preloadedExtensions) {
1930
1958
  extensionsResult = {
1931
1959
  ...options.preloadedExtensions,
1932
1960
  extensions: [...options.preloadedExtensions.extensions],
@@ -2235,11 +2263,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2235
2263
  }
2236
2264
  }
2237
2265
 
2238
- // Discover custom commands (TypeScript slash commands)
2239
- const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
2240
- ? { commands: [], errors: [] }
2241
- : await logger.time("discoverCustomCommands", loadCustomCommandsInternal, { cwd, agentDir });
2242
- if (!options.disableExtensionDiscovery) {
2266
+ // Restricted sessions do not discover or evaluate custom command modules.
2267
+ const customCommandsResult: CustomCommandsLoadResult =
2268
+ options.disableExtensionDiscovery || restrictToolNames
2269
+ ? { commands: [], errors: [] }
2270
+ : await logger.time("discoverCustomCommands", loadCustomCommandsInternal, { cwd, agentDir });
2271
+ if (!options.disableExtensionDiscovery && !restrictToolNames) {
2243
2272
  for (const { path, error } of customCommandsResult.errors) {
2244
2273
  logger.error("Failed to load custom command", { path, error });
2245
2274
  }
@@ -2284,8 +2313,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2284
2313
  });
2285
2314
  const toolContextStore = new ToolContextStore(getSessionContext);
2286
2315
 
2287
- const registeredTools = extensionRunner.getAllRegisteredTools();
2288
- const sdkCustomTools = options.customTools?.filter(tool => !isLegacyBuiltinToolDefinition(tool)) ?? [];
2316
+ const registeredTools = restrictToolNames ? [] : extensionRunner.getAllRegisteredTools();
2317
+ const sdkCustomTools = restrictToolNames
2318
+ ? []
2319
+ : (options.customTools?.filter(tool => !isLegacyBuiltinToolDefinition(tool)) ?? []);
2289
2320
  const allCustomTools = [
2290
2321
  ...registeredTools,
2291
2322
  ...sdkCustomTools.map(tool => {
@@ -2308,7 +2339,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2308
2339
  toolRegistry.set(tool.name, tool);
2309
2340
  builtInRegistryToolNames.add(tool.name);
2310
2341
  }
2311
- if (!toolRegistry.has("goal") && settings.get("goal.enabled")) {
2342
+ if (!restrictToolNames && !toolRegistry.has("goal") && settings.get("goal.enabled")) {
2312
2343
  const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
2313
2344
  if (goalTool) {
2314
2345
  toolRegistry.set(goalTool.name, wrapToolWithMetaNotice(goalTool));
@@ -2361,7 +2392,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2361
2392
  const hasDeferrableTools = Array.from(toolRegistry.values()).some(tool => tool.deferrable === true);
2362
2393
  const hasXdevTools = (toolSession.xdevRegistry?.size ?? 0) > 0;
2363
2394
  const planModeAvailable = settings.get("plan.enabled");
2364
- if (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI) {
2395
+ if (!restrictToolNames && (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI)) {
2365
2396
  await ensureWriteRegistered();
2366
2397
  }
2367
2398
 
@@ -2401,8 +2432,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2401
2432
  ): Promise<BuildSystemPromptResult> => {
2402
2433
  toolContextStore.setToolNames(toolNames);
2403
2434
  const promptTools = buildSystemPromptToolMetadata(tools);
2404
- const memoryBackend = await resolveMemoryBackend(settings);
2405
- const memoryInstructions = await memoryBackend.buildDeveloperInstructions(agentDir, settings, session);
2435
+ const memoryBackend = restrictToolNames ? undefined : await resolveMemoryBackend(settings);
2436
+ const memoryInstructions = memoryBackend
2437
+ ? await memoryBackend.buildDeveloperInstructions(agentDir, settings, session)
2438
+ : undefined;
2406
2439
 
2407
2440
  // Build combined append prompt: memory instructions + auto-learn guidance
2408
2441
  // + MCP server instructions. For UI sessions MCP discovery is deferred, so
@@ -2417,10 +2450,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2417
2450
  // session-start build — so a subagent that filtered them out, a mid-session
2418
2451
  // enable that never built them, or a same-named custom tool while auto-learn
2419
2452
  // is off all get no guidance.
2420
- const autoLearnInstructions = buildAutoLearnInstructions({
2421
- manageSkill: builtInToolNames.includes("manage_skill"),
2422
- learn: builtInToolNames.includes("learn"),
2423
- });
2453
+ const autoLearnInstructions = restrictToolNames
2454
+ ? undefined
2455
+ : buildAutoLearnInstructions({
2456
+ manageSkill: builtInToolNames.includes("manage_skill"),
2457
+ learn: builtInToolNames.includes("learn"),
2458
+ });
2424
2459
  const appendParts: string[] = [];
2425
2460
  if (memoryInstructions) appendParts.push(memoryInstructions);
2426
2461
  if (autoLearnInstructions) appendParts.push(autoLearnInstructions);
@@ -2452,7 +2487,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2452
2487
  cwd,
2453
2488
  xdevTools: toolSession.xdevRegistry?.entries() ?? [],
2454
2489
  xdevDocs: toolSession.xdevRegistry?.docsAll() ?? "",
2455
- autoQaEnabled: isAutoQaEnabled(settings),
2490
+ autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings),
2456
2491
  resolvedCustomPrompt: options.customSystemPrompt,
2457
2492
  skills: session?.skills ?? skills,
2458
2493
  contextFiles,
@@ -2469,11 +2504,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2469
2504
  eagerTasksAlways,
2470
2505
  taskBatch: settings.get("task.batch"),
2471
2506
  taskMaxConcurrency: settings.get("task.maxConcurrency"),
2472
- taskIrcEnabled: isIrcEnabled(settings, options.taskDepth ?? 0),
2507
+ taskIrcEnabled: !restrictToolNames && isIrcEnabled(settings, options.taskDepth ?? 0),
2473
2508
  secretsEnabled,
2474
2509
  workspaceTree: workspaceTreePromise,
2475
2510
  includeWorkspaceTree,
2476
- memoryRootEnabled: memoryBackend.id === "local",
2511
+ memoryRootEnabled: memoryBackend?.id === "local",
2477
2512
  model: getActiveModelString(),
2478
2513
  includeModelInPrompt: settings.get("includeModelInPrompt"),
2479
2514
  personality: agentKind === "sub" ? "none" : settings.get("personality"),
@@ -2514,7 +2549,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2514
2549
  // exactly the builtins createTools built (`builtInToolNames` — provenance, so a
2515
2550
  // same-named custom/extension tool is never force-activated when auto-learn is
2516
2551
  // off) to keep guidance, controller, and the active set consistent.
2517
- if (explicitlyRequestedToolNames) {
2552
+ if (!restrictToolNames && explicitlyRequestedToolNames) {
2518
2553
  for (const name of ["manage_skill", "learn"]) {
2519
2554
  if (builtInToolNames.includes(name) && !explicitlyRequestedToolNames.includes(name)) {
2520
2555
  explicitlyRequestedToolNames.push(name);
@@ -2538,11 +2573,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2538
2573
  : requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
2539
2574
  let initialToolNames = [...initialRequestedActiveToolNames];
2540
2575
 
2541
- // Custom tools and extension-registered tools are always included regardless of toolNames filter
2542
- const alwaysInclude: string[] = [
2543
- ...sdkCustomTools.map(t => (isCustomTool(t) ? t.name : t.name)),
2544
- ...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
2545
- ];
2576
+ // Custom tools and extension-registered tools are always included regardless of toolNames filter.
2577
+ // Restricted callers own the list, so never widen it with registered tools.
2578
+ const alwaysInclude: string[] = restrictToolNames
2579
+ ? []
2580
+ : [
2581
+ ...sdkCustomTools.map(t => (isCustomTool(t) ? t.name : t.name)),
2582
+ ...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
2583
+ ];
2546
2584
  for (const name of alwaysInclude) {
2547
2585
  if (toolRegistry.has(name) && !initialToolNames.includes(name)) {
2548
2586
  initialToolNames.push(name);
@@ -2712,6 +2750,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2712
2750
  }
2713
2751
  return result;
2714
2752
  };
2753
+ const kimiApiFormatSetting = settings.get("providers.kimiApiFormat");
2754
+ const kimiApiFormat = kimiApiFormatSetting === "auto" ? undefined : kimiApiFormatSetting;
2715
2755
  agent = new Agent({
2716
2756
  initialState: {
2717
2757
  systemPrompt,
@@ -2745,7 +2785,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2745
2785
  presencePenalty: settings.get("presencePenalty") >= 0 ? settings.get("presencePenalty") : undefined,
2746
2786
  repetitionPenalty: settings.get("repetitionPenalty") >= 0 ? settings.get("repetitionPenalty") : undefined,
2747
2787
  hideThinkingSummary: settings.get("omitThinking"),
2748
- kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
2788
+ kimiApiFormat,
2749
2789
  preferWebsockets: preferOpenAICodexWebsockets,
2750
2790
  getToolContext: tc => toolContextStore.getContext(tc),
2751
2791
  getApiKey: requestModel => modelRegistry.resolver(requestModel, agent.sessionId),
@@ -3078,7 +3118,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3078
3118
  serviceTierResolver: agent.serviceTierResolver,
3079
3119
  hideThinkingSummary: agent.hideThinkingSummary,
3080
3120
  maxRetryDelayMs: agent.maxRetryDelayMs,
3081
- kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
3121
+ kimiApiFormat,
3082
3122
  preferWebsockets: preferOpenAICodexWebsockets,
3083
3123
  getToolContext: toolCall => toolContextStore.getContext(toolCall),
3084
3124
  streamFn: settingsAwareStreamFn,
@@ -3111,15 +3151,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3111
3151
  // and the tools; the fire-time re-check in `#onAgentEnd` still handles a
3112
3152
  // mid-session DISABLE. The subscription lives for the session's lifetime; the
3113
3153
  // reference is intentionally discarded (the listener retains it).
3114
- if (settings.get("autolearn.enabled") && taskDepth === 0) {
3115
- await logger.time("startMemoryStartupTask", startMemoryBackend);
3116
- new AutoLearnController({
3117
- session,
3118
- settings,
3119
- capture: content => session.runAutolearnCapture(signal => runAutoLearnCapture(content, signal)),
3120
- });
3121
- } else {
3122
- void logger.time("startMemoryStartupTask", startMemoryBackend);
3154
+ if (!restrictToolNames) {
3155
+ if (settings.get("autolearn.enabled") && taskDepth === 0) {
3156
+ await logger.time("startMemoryStartupTask", startMemoryBackend);
3157
+ new AutoLearnController({
3158
+ session,
3159
+ settings,
3160
+ capture: content => session.runAutolearnCapture(signal => runAutoLearnCapture(content, signal)),
3161
+ });
3162
+ } else {
3163
+ void logger.time("startMemoryStartupTask", startMemoryBackend);
3164
+ }
3123
3165
  }
3124
3166
 
3125
3167
  // Wire MCP manager callbacks to session for reactive tool updates.
@@ -2095,6 +2095,9 @@ export class AgentSession {
2095
2095
  #xdevRegistry: XdevRegistry | undefined;
2096
2096
  /** Names of discoverable tools currently mounted under `xd://` (dynamic mounts only, not built-in devices). */
2097
2097
  #mountedXdevToolNames = new Set<string>();
2098
+ /** Coalesced xd:// mount delta not yet announced to the model; delivered as a
2099
+ * hidden notice alongside the next prompt (see {@link #notifyXdevMountDelta}). */
2100
+ #pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
2098
2101
 
2099
2102
  // TTSR manager for time-traveling stream rules
2100
2103
  #ttsrManager: TtsrManager | undefined = undefined;
@@ -2242,6 +2245,17 @@ export class AgentSession {
2242
2245
  * queue was consumed normally or a new turn already started. */
2243
2246
  #drainStrandedQueuedMessages(): void {
2244
2247
  if (this.#abortInProgress) return;
2248
+ // Session transitions (newSession/`/new`, compact, model-switch, session-switch,
2249
+ // dispose) call #disconnectFromAgent() BEFORE `await abort()`, so abort's own
2250
+ // finally lands here with no listener attached. Auto-resuming now would snapshot
2251
+ // the still-old context (the transition hasn't reached agent.reset() yet), start a
2252
+ // stale provider turn that races the reset, and — once reconnected — append its
2253
+ // output to the fresh session (issue #5800). A disconnected session never owns the
2254
+ // queue: the transition does. newSession/switchSession drop the queue (reset /
2255
+ // clearAllQueues), so nothing survives; compaction preserves it and re-drains itself
2256
+ // after #reconnectToAgent (see compact()'s finally); an explicit prompt flushes it
2257
+ // in every case.
2258
+ if (this.#unsubscribeAgent === undefined) return;
2245
2259
  // A concern steered into a resumed streaming run after a user interrupt can
2246
2260
  // strand at the turn tail (steered past the loop's final boundary poll). While
2247
2261
  // that interrupt's suppression is still in effect, reclaim such advisor steers
@@ -7307,35 +7321,62 @@ export class AgentSession {
7307
7321
  }
7308
7322
 
7309
7323
  /**
7310
- * Announce a mid-session `xd://` mount delta to the model as a steered
7311
- * system notice instead of rewriting the system prompt: the prompt (and
7312
- * its provider cache prefix) stays byte-stable across MCP connects and
7313
- * disconnects, and the model learns about new devices from the notice
7314
- * (docs + schema stay one `read xd://<tool>` away). The full docs join
7315
- * the system prompt opportunistically on the next unrelated rebuild.
7324
+ * Record a mid-session `xd://` mount delta for the model without rewriting
7325
+ * the system prompt: the prompt (and its provider cache prefix) stays
7326
+ * byte-stable across MCP connects and disconnects. The delta is NOT steered
7327
+ * immediately a steered notice landing at a run's stop boundary (or while
7328
+ * the session is idle) forces an unsolicited extra assistant turn it is
7329
+ * coalesced into {@link #pendingXdevMountDelta} and rides along with the
7330
+ * next prompt (docs + schema stay one `read xd://<tool>` away). The full
7331
+ * docs join the system prompt opportunistically on the next unrelated
7332
+ * rebuild.
7316
7333
  */
7317
7334
  #notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
7318
7335
  const registry = this.#xdevRegistry;
7319
7336
  if (!registry) return;
7320
7337
  const current = this.#mountedXdevToolNames;
7321
7338
  const addedNames = [...current].filter(name => !previousMounted.has(name));
7322
- const removed = [...previousMounted].filter(name => !current.has(name)).map(name => ({ name }));
7323
- if (addedNames.length === 0 && removed.length === 0) return;
7324
- const summaries = new Map(registry.entries().map(entry => [entry.name, entry.summary]));
7325
- const added = addedNames.map(name => ({ name, summary: summaries.get(name) ?? "" }));
7326
- this.agent.steer({
7339
+ const removedNames = [...previousMounted].filter(name => !current.has(name));
7340
+ if (addedNames.length === 0 && removedNames.length === 0) return;
7341
+ // Coalesce against the unannounced delta: an unmount cancels a pending
7342
+ // mount the model never learned about, and a remount cancels a pending
7343
+ // unmount.
7344
+ const pending = this.#pendingXdevMountDelta ?? { added: new Set<string>(), removed: new Set<string>() };
7345
+ for (const name of addedNames) {
7346
+ if (!pending.removed.delete(name)) pending.added.add(name);
7347
+ }
7348
+ for (const name of removedNames) {
7349
+ if (!pending.added.delete(name)) pending.removed.add(name);
7350
+ }
7351
+ this.#pendingXdevMountDelta = pending.added.size > 0 || pending.removed.size > 0 ? pending : undefined;
7352
+ if (this.settings.get("startup.quiet")) return;
7353
+ const parts: string[] = [];
7354
+ if (addedNames.length > 0) parts.push(`mounted ${addedNames.join(", ")}`);
7355
+ if (removedNames.length > 0) parts.push(`unmounted ${removedNames.join(", ")}`);
7356
+ this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
7357
+ }
7358
+
7359
+ /**
7360
+ * Render and consume the pending xd:// mount delta as a hidden notice, or
7361
+ * `undefined` when nothing unannounced is queued. Called from the prompt
7362
+ * paths so the notice rides along with user input instead of forcing its
7363
+ * own model turn.
7364
+ */
7365
+ #takePendingXdevMountNotice(): CustomMessage | undefined {
7366
+ const pending = this.#pendingXdevMountDelta;
7367
+ if (!pending) return undefined;
7368
+ this.#pendingXdevMountDelta = undefined;
7369
+ const summaries = new Map(this.#xdevRegistry?.entries().map(entry => [entry.name, entry.summary]) ?? []);
7370
+ const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
7371
+ const removed = [...pending.removed].map(name => ({ name }));
7372
+ return {
7327
7373
  role: "custom",
7328
7374
  customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
7329
7375
  content: prompt.render(xdevMountNoticePrompt, { added, removed }),
7330
7376
  attribution: "agent",
7331
7377
  display: false,
7332
7378
  timestamp: Date.now(),
7333
- });
7334
- if (this.settings.get("startup.quiet")) return;
7335
- const parts: string[] = [];
7336
- if (added.length > 0) parts.push(`mounted ${added.map(entry => entry.name).join(", ")}`);
7337
- if (removed.length > 0) parts.push(`unmounted ${removed.map(entry => entry.name).join(", ")}`);
7338
- this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
7379
+ };
7339
7380
  }
7340
7381
 
7341
7382
  /**
@@ -8693,14 +8734,19 @@ export class AgentSession {
8693
8734
  messages.push(...options.prependMessages);
8694
8735
  }
8695
8736
 
8696
- messages.push(message);
8697
-
8698
8737
  // Early bail-out: if a newer abort/prompt cycle started during setup,
8699
8738
  // return before mutating shared state (nextTurn messages, system prompt).
8700
8739
  if (this.#promptGeneration !== generation) {
8701
8740
  return;
8702
8741
  }
8703
8742
 
8743
+ // A pending xd:// delta accompanies the next user-authored prompt,
8744
+ // never an agent-initiated continuation.
8745
+ const xdevMountNotice = isUserQueuedMessage(message) ? this.#takePendingXdevMountNotice() : undefined;
8746
+ if (xdevMountNotice) {
8747
+ messages.push(xdevMountNotice);
8748
+ }
8749
+ messages.push(message);
8704
8750
  // Inject any pending "nextTurn" messages as context alongside the user message
8705
8751
  for (const msg of this.#pendingNextTurnMessages) {
8706
8752
  messages.push(msg);
@@ -11014,6 +11060,13 @@ export class AgentSession {
11014
11060
  this.#compactionAbortController = undefined;
11015
11061
  }
11016
11062
  this.#reconnectToAgent();
11063
+ // Compaction disconnected before `await abort()`, so abort's finally drain
11064
+ // (and any steer/follow-up that arrived mid-compaction — async IRC, an
11065
+ // `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected
11066
+ // (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent
11067
+ // queues, so nothing else resumes them: re-drain now that the listener is back
11068
+ // and `isCompacting` is false, or the queued turn hangs until the next prompt.
11069
+ this.#drainStrandedQueuedMessages();
11017
11070
  }
11018
11071
  }
11019
11072
 
@@ -11912,11 +11965,12 @@ export class AgentSession {
11912
11965
  #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
11913
11966
  switch (assistantMessage.stopReason) {
11914
11967
  case "stop":
11915
- // Reasoning/thinking-only turns are not actionable: they do not
11916
- // answer the user and do not give the agent loop a tool call to run.
11968
+ // Unsigned thinking alone is not actionable, but a signature is
11969
+ // provider-authenticated content and makes the stop terminal.
11917
11970
  for (const content of assistantMessage.content) {
11918
11971
  if (content.type === "toolCall") return false;
11919
11972
  if (content.type === "text" && hasNonWhitespace(content.text)) return false;
11973
+ if (content.type === "thinking" && hasNonWhitespace(content.thinkingSignature ?? "")) return false;
11920
11974
  }
11921
11975
  return true;
11922
11976
  case "toolUse":
@@ -1,5 +1,6 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { ImageContent, MessageAttribution, ServiceTierByFamily, TextContent } from "@oh-my-pi/pi-ai";
3
+ import type { StructuredSubagentSchemaMode } from "../task/types";
3
4
 
4
5
  export const CURRENT_SESSION_VERSION = 3;
5
6
 
@@ -164,8 +165,12 @@ export interface SessionInitEntry extends SessionEntryBase {
164
165
  task: string;
165
166
  /** Tools available to the agent */
166
167
  tools: string[];
167
- /** Output schema if structured output was requested */
168
+ /** Output schema if structured output was requested. */
168
169
  outputSchema?: unknown;
170
+ /** Enforcement policy recorded with the output schema for faithful revival. */
171
+ outputSchemaMode?: StructuredSubagentSchemaMode;
172
+ /** Whether revival must retain only the explicitly persisted tool names. */
173
+ restrictToolNames?: boolean;
169
174
  /** Spawn allowlist the subagent ran with ("" = none, "*" = any, else CSV); absent on pre-spawns files. */
170
175
  spawns?: string;
171
176
  /** The agent's `readSummarize` setting (`false` = read summarization disabled); absent uses the session default. */
@@ -271,10 +271,38 @@ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, ke
271
271
  );
272
272
  }
273
273
 
274
+ /**
275
+ * Cheap synchronous precheck: does this value's tree contain any `blob:sha256:` string?
276
+ * Early-exits on the first hit and allocates no promises, so blob-free entries skip the
277
+ * async {@link resolvePersistedBlobRefs} descent entirely. Conservative — a blob ref in a
278
+ * non-resolved position still returns true, which only costs an extra (no-op) walk.
279
+ */
280
+ function containsBlobRef(value: unknown): boolean {
281
+ if (typeof value === "string") return isBlobRef(value);
282
+ if (Array.isArray(value)) {
283
+ for (const item of value) {
284
+ if (containsBlobRef(item)) return true;
285
+ }
286
+ return false;
287
+ }
288
+ if (typeof value !== "object" || value === null) return false;
289
+ for (const key in value) {
290
+ if (containsBlobRef((value as Record<string, unknown>)[key])) return true;
291
+ }
292
+ return false;
293
+ }
294
+
274
295
  export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
275
- await Promise.all(
276
- entries.filter(entry => entry.type !== "session").map(entry => resolvePersistedBlobRefs(entry, blobStore)),
277
- );
296
+ const pending: Promise<void>[] = [];
297
+ // Interleave precheck + initiation per entry so a positive entry begins resolution at the same
298
+ // relative point as the old filter+map schedule (no scan-all-first pass that could observe a
299
+ // later entry before an earlier resolution mutates it).
300
+ for (const entry of entries) {
301
+ if (entry.type === "session") continue;
302
+ if (!containsBlobRef(entry)) continue;
303
+ pending.push(resolvePersistedBlobRefs(entry, blobStore));
304
+ }
305
+ await Promise.all(pending);
278
306
  }
279
307
 
280
308
  /**