@oh-my-pi/pi-coding-agent 14.9.3 → 14.9.7

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 (108) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/package.json +7 -7
  3. package/src/async/job-manager.ts +66 -9
  4. package/src/capability/rule.ts +20 -0
  5. package/src/cli/setup-cli.ts +14 -161
  6. package/src/cli/stats-cli.ts +56 -2
  7. package/src/cli.ts +0 -1
  8. package/src/config/model-registry.ts +13 -0
  9. package/src/config/model-resolver.ts +8 -2
  10. package/src/config/settings-schema.ts +1 -11
  11. package/src/edit/index.ts +8 -0
  12. package/src/edit/renderer.ts +6 -1
  13. package/src/edit/streaming.ts +53 -2
  14. package/src/eval/eval.lark +30 -10
  15. package/src/eval/js/context-manager.ts +334 -601
  16. package/src/eval/js/shared/helpers.ts +237 -0
  17. package/src/eval/js/shared/indirect-eval.ts +30 -0
  18. package/src/eval/js/{prelude.txt → shared/prelude.txt} +0 -2
  19. package/src/eval/js/shared/rewrite-imports.ts +211 -0
  20. package/src/eval/js/shared/runtime.ts +168 -0
  21. package/src/eval/js/shared/types.ts +18 -0
  22. package/src/eval/js/tool-bridge.ts +2 -4
  23. package/src/eval/js/worker-core.ts +146 -0
  24. package/src/eval/js/worker-entry.ts +24 -0
  25. package/src/eval/js/worker-protocol.ts +41 -0
  26. package/src/eval/parse.ts +218 -49
  27. package/src/eval/py/display.ts +71 -0
  28. package/src/eval/py/executor.ts +97 -96
  29. package/src/eval/py/index.ts +2 -2
  30. package/src/eval/py/kernel.ts +472 -900
  31. package/src/eval/py/prelude.py +106 -87
  32. package/src/eval/py/runner.py +879 -0
  33. package/src/eval/py/runtime.ts +3 -16
  34. package/src/eval/py/tool-bridge.ts +137 -0
  35. package/src/export/html/template.css +12 -0
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +113 -7
  38. package/src/extensibility/plugins/loader.ts +31 -6
  39. package/src/extensibility/skills.ts +20 -0
  40. package/src/internal-urls/agent-protocol.ts +63 -52
  41. package/src/internal-urls/artifact-protocol.ts +51 -51
  42. package/src/internal-urls/docs-index.generated.ts +35 -3
  43. package/src/internal-urls/index.ts +6 -19
  44. package/src/internal-urls/local-protocol.ts +49 -7
  45. package/src/internal-urls/mcp-protocol.ts +2 -8
  46. package/src/internal-urls/memory-protocol.ts +89 -59
  47. package/src/internal-urls/router.ts +38 -22
  48. package/src/internal-urls/rule-protocol.ts +2 -20
  49. package/src/internal-urls/skill-protocol.ts +4 -27
  50. package/src/main.ts +1 -1
  51. package/src/mcp/manager.ts +17 -0
  52. package/src/modes/components/session-observer-overlay.ts +2 -2
  53. package/src/modes/components/tool-execution.ts +6 -0
  54. package/src/modes/components/tree-selector.ts +4 -0
  55. package/src/modes/controllers/command-controller.ts +0 -23
  56. package/src/modes/controllers/event-controller.ts +23 -2
  57. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  58. package/src/modes/interactive-mode.ts +2 -2
  59. package/src/modes/theme/theme.ts +27 -27
  60. package/src/modes/types.ts +1 -1
  61. package/src/modes/utils/ui-helpers.ts +14 -9
  62. package/src/prompts/commands/orchestrate.md +1 -0
  63. package/src/prompts/system/project-prompt.md +10 -2
  64. package/src/prompts/system/subagent-system-prompt.md +8 -8
  65. package/src/prompts/system/system-prompt.md +13 -7
  66. package/src/prompts/tools/ask.md +0 -1
  67. package/src/prompts/tools/bash.md +0 -10
  68. package/src/prompts/tools/eval.md +15 -30
  69. package/src/prompts/tools/github.md +6 -5
  70. package/src/prompts/tools/hashline.md +1 -0
  71. package/src/prompts/tools/job.md +14 -6
  72. package/src/prompts/tools/task.md +20 -3
  73. package/src/registry/agent-registry.ts +2 -1
  74. package/src/sdk.ts +87 -89
  75. package/src/session/agent-session.ts +58 -21
  76. package/src/session/artifacts.ts +7 -4
  77. package/src/session/history-storage.ts +77 -19
  78. package/src/session/session-manager.ts +30 -1
  79. package/src/ssh/connection-manager.ts +32 -16
  80. package/src/ssh/sshfs-mount.ts +10 -7
  81. package/src/system-prompt.ts +0 -5
  82. package/src/task/executor.ts +14 -2
  83. package/src/task/index.ts +19 -5
  84. package/src/tool-discovery/tool-index.ts +21 -8
  85. package/src/tools/ast-edit.ts +3 -2
  86. package/src/tools/ast-grep.ts +3 -2
  87. package/src/tools/bash.ts +15 -9
  88. package/src/tools/browser/tab-protocol.ts +4 -0
  89. package/src/tools/browser/tab-supervisor.ts +98 -7
  90. package/src/tools/browser/tab-worker.ts +104 -58
  91. package/src/tools/eval.ts +49 -11
  92. package/src/tools/fetch.ts +1 -1
  93. package/src/tools/gh.ts +140 -4
  94. package/src/tools/index.ts +12 -11
  95. package/src/tools/job.ts +48 -12
  96. package/src/tools/read.ts +5 -4
  97. package/src/tools/search.ts +3 -2
  98. package/src/tools/todo-write.ts +1 -1
  99. package/src/web/scrapers/mastodon.ts +1 -1
  100. package/src/web/scrapers/repology.ts +7 -7
  101. package/src/web/search/index.ts +6 -4
  102. package/src/cli/jupyter-cli.ts +0 -106
  103. package/src/commands/jupyter.ts +0 -32
  104. package/src/eval/py/cancellation.ts +0 -28
  105. package/src/eval/py/gateway-coordinator.ts +0 -424
  106. package/src/internal-urls/jobs-protocol.ts +0 -120
  107. package/src/prompts/system/now-prompt.md +0 -7
  108. /package/src/eval/js/{prelude.ts → shared/prelude.ts} +0 -0
@@ -1,11 +1,16 @@
1
1
  Launches subagents to parallelize workflows.
2
2
 
3
3
  {{#if asyncEnabled}}
4
- - `read jobs://` for state, `read jobs://<id>` for detail.
5
- - Use `job` (with `poll`) to wait. **MUST NOT** poll `read jobs://` in a loop.
4
+ - Results are delivered automatically when complete.
5
+ - If genuinely blocked on task completion, wait with `job` using `poll`; otherwise continue with another task when possible.
6
+ - Call `job` with `list: true` to snapshot manager state; pass `poll: [id]` to wait or `cancel: [id]` to stop \u2014 only when inspection or intervention is useful.
6
7
  {{/if}}
7
8
 
8
- Subagents have no conversation history. Every fact, file path, and decision they need **MUST** be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
9
+ {{#if ircEnabled}}
10
+ Subagents have no conversation history, but they can reach you and their siblings live via the `irc` tool. Front-load every fact, file path, and direction they need in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
11
+ {{else}}
12
+ Subagents have no conversation history. Every fact, file path, and direction they need **MUST** be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
13
+ {{/if}}
9
14
 
10
15
  <parameters>
11
16
  - `agent`: agent type for all tasks
@@ -20,16 +25,28 @@ Subagents have no conversation history. Every fact, file path, and decision they
20
25
 
21
26
  <rules>
22
27
  - **MUST NOT** assign tasks to run project-wide build/test/lint. Caller verifies after the batch.
28
+ - **Subagents do not verify, lint, or format.** Every assignment **MUST** instruct the subagent to skip all gates and formatters. You run them once at the end across the union of changed files — avoids redundant runs and racing formatter passes.
29
+ {{#if ircEnabled}}
30
+ - Each task: ≤3–5 explicit files. Overlapping file sets are tolerable when peers can coordinate via `irc`, but still fan out to a cluster when the scopes are cleanly separable.
31
+ - No globs, no "update all", no package-wide scope.
32
+ {{else}}
23
33
  - Each task: ≤3–5 explicit files. No globs, no "update all", no package-wide scope. Fan out to a cluster instead.
34
+ {{/if}}
24
35
  - Pass large payloads via `local://<path>` URIs, not inline.
25
36
  {{#if contextEnabled}}- Put shared constraints in `context` once; do not duplicate across assignments.{{/if}}
26
37
  - Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
27
38
  </rules>
28
39
 
29
40
  <parallelization>
41
+ {{#if ircEnabled}}
42
+ Test: can task B run correctly without seeing A's output? If no, sequence A → B — **unless** B can reasonably ask A for the missing piece over `irc`. Live coordination beats a serial waterfall when the contract is small and easy to describe in a DM.
43
+ Still sequence when one task produces a large, evolving contract (generated types, schema migration, core module API) the other consumes wholesale — IRC round-trips do not replace a finished artifact.
44
+ Parallel when tasks touch disjoint files, are independent refactors/tests, or only need occasional clarification that can be resolved peer-to-peer.
45
+ {{else}}
30
46
  Test: can task B run correctly without seeing A's output? If no, sequence A → B.
31
47
  Sequential when one task produces a contract (types, API, schema, core module) the other consumes.
32
48
  Parallel when tasks touch disjoint files or are independent refactors/tests.
49
+ {{/if}}
33
50
  </parallelization>
34
51
 
35
52
  {{#if contextEnabled}}
@@ -86,10 +86,11 @@ export class AgentRegistry {
86
86
  this.#emit({ type: "status_changed", ref });
87
87
  }
88
88
 
89
- attachSession(id: string, session: AgentSession): void {
89
+ attachSession(id: string, session: AgentSession, sessionFile?: string | null): void {
90
90
  const ref = this.#refs.get(id);
91
91
  if (!ref) return;
92
92
  ref.session = session;
93
+ if (sessionFile !== undefined) ref.sessionFile = sessionFile;
93
94
  ref.lastActivity = Date.now();
94
95
  }
95
96
 
package/src/sdk.ts CHANGED
@@ -27,7 +27,7 @@ import chalk from "chalk";
27
27
  import { AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
28
28
  import { createAutoresearchExtension } from "./autoresearch";
29
29
  import { loadCapability } from "./capability";
30
- import { type Rule, ruleCapability } from "./capability/rule";
30
+ import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
31
31
  import { ModelRegistry } from "./config/model-registry";
32
32
  import { formatModelString, parseModelPattern, parseModelString, resolveModelRoleValue } from "./config/model-resolver";
33
33
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
@@ -59,30 +59,22 @@ import {
59
59
  type ToolDefinition,
60
60
  wrapRegisteredTools,
61
61
  } from "./extensibility/extensions";
62
- import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./extensibility/skills";
62
+ import {
63
+ loadSkills as loadSkillsInternal,
64
+ type Skill,
65
+ type SkillWarning,
66
+ setActiveSkills,
67
+ } from "./extensibility/skills";
63
68
  import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./extensibility/slash-commands";
64
69
  import type { HindsightSessionState } from "./hindsight/state";
65
- import {
66
- AgentProtocolHandler,
67
- ArtifactProtocolHandler,
68
- InternalUrlRouter,
69
- JobsProtocolHandler,
70
- LocalProtocolHandler,
71
- type LocalProtocolOptions,
72
- McpProtocolHandler,
73
- MemoryProtocolHandler,
74
- PiProtocolHandler,
75
- RuleProtocolHandler,
76
- SkillProtocolHandler,
77
- } from "./internal-urls";
70
+ import { LocalProtocolHandler, type LocalProtocolOptions } from "./internal-urls";
78
71
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "./lsp/startup-events";
79
- import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
72
+ import { discoverAndLoadMCPTools, MCPManager, type MCPToolsLoadResult } from "./mcp";
80
73
  import {
81
74
  collectDiscoverableMCPTools,
82
75
  formatDiscoverableMCPToolServerSummary,
83
76
  selectDiscoverableMCPToolNamesByServer,
84
77
  } from "./mcp/discoverable-tool-metadata";
85
- import { getMemoryRoot } from "./memories";
86
78
  import { resolveMemoryBackend } from "./memory-backend";
87
79
  import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
88
80
  import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
@@ -917,6 +909,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
917
909
  let agent: Agent;
918
910
  let session!: AgentSession;
919
911
  let hasSession = false;
912
+ let hasRegistered = false;
920
913
  const enableLsp = options.enableLsp ?? true;
921
914
  const backgroundJobsEnabled = isBackgroundJobSupportEnabled(settings);
922
915
  const asyncMaxJobs = Math.min(100, Math.max(1, settings.get("async.maxJobs") ?? 100));
@@ -942,34 +935,39 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
942
935
 
943
936
  return preview;
944
937
  };
945
- const asyncJobManager = backgroundJobsEnabled
946
- ? new AsyncJobManager({
947
- maxRunningJobs: asyncMaxJobs,
948
- onJobComplete: async (jobId, result, job) => {
949
- if (!session || asyncJobManager!.isDeliverySuppressed(jobId)) return;
950
- const formattedResult = await formatAsyncResultForFollowUp(result);
951
- if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
952
-
953
- const message = prompt.render(asyncResultTemplate, { jobId, result: formattedResult });
954
- const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
955
- await session.sendCustomMessage(
956
- {
957
- customType: "async-result",
958
- content: message,
959
- display: true,
960
- attribution: "agent",
961
- details: {
962
- jobId,
963
- type: job?.type,
964
- label: job?.label,
965
- durationMs,
938
+ // Only top-level sessions own an AsyncJobManager. Subagents reach the
939
+ // parent's manager via `AsyncJobManager.instance()` (set below), so creating
940
+ // a second instance here just to leave it orphaned wastes a constructor and
941
+ // risks accidental disposal of the parent's manager on subagent teardown.
942
+ const asyncJobManager =
943
+ backgroundJobsEnabled && !options.parentTaskPrefix
944
+ ? new AsyncJobManager({
945
+ maxRunningJobs: asyncMaxJobs,
946
+ onJobComplete: async (jobId, result, job) => {
947
+ if (!session || asyncJobManager!.isDeliverySuppressed(jobId)) return;
948
+ const formattedResult = await formatAsyncResultForFollowUp(result);
949
+ if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
950
+
951
+ const message = prompt.render(asyncResultTemplate, { jobId, result: formattedResult });
952
+ const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
953
+ await session.sendCustomMessage(
954
+ {
955
+ customType: "async-result",
956
+ content: message,
957
+ display: true,
958
+ attribution: "agent",
959
+ details: {
960
+ jobId,
961
+ type: job?.type,
962
+ label: job?.label,
963
+ durationMs,
964
+ },
966
965
  },
967
- },
968
- { deliverAs: "followUp", triggerTurn: true },
969
- );
970
- },
971
- })
972
- : undefined;
966
+ { deliverAs: "followUp", triggerTurn: true },
967
+ );
968
+ },
969
+ })
970
+ : undefined;
973
971
 
974
972
  const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
975
973
  const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
@@ -1055,44 +1053,27 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1055
1053
  return {};
1056
1054
  }
1057
1055
  },
1056
+ getArtifactManager: () => sessionManager.getArtifactManager(),
1058
1057
  settings,
1059
1058
  authStorage,
1060
1059
  modelRegistry,
1061
- asyncJobManager,
1062
1060
  };
1063
1061
 
1064
- // Initialize internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, local://)
1065
- const internalRouter = new InternalUrlRouter();
1062
+ // Wire process-wide internal URL singletons owned by their real classes.
1063
+ // Top-level sessions install the active snapshots; subagents inherit them.
1064
+ // Artifact and agent-output URLs resolve via `AgentRegistry.global()` —
1065
+ // the protocol handlers walk each ref's `sessionManager.getArtifactsDir()`,
1066
+ // which collapses to the parent's dir for subagents (they adopt the
1067
+ // parent's ArtifactManager) so one lookup hits everything.
1066
1068
  const getArtifactsDir = () => sessionManager.getArtifactsDir();
1067
- internalRouter.register(new AgentProtocolHandler({ getArtifactsDir }));
1068
- internalRouter.register(new ArtifactProtocolHandler({ getArtifactsDir }));
1069
- internalRouter.register(
1070
- new MemoryProtocolHandler({
1071
- getMemoryRoot: () => getMemoryRoot(agentDir, settings.getCwd()),
1072
- }),
1073
- );
1074
- internalRouter.register(
1075
- new LocalProtocolHandler(
1076
- options.localProtocolOptions ?? {
1077
- getArtifactsDir,
1078
- getSessionId: () => sessionManager.getSessionId(),
1079
- },
1080
- ),
1081
- );
1082
- internalRouter.register(
1083
- new SkillProtocolHandler({
1084
- getSkills: () => skills,
1085
- }),
1086
- );
1087
- internalRouter.register(
1088
- new RuleProtocolHandler({
1089
- getRules: () => [...rulebookRules, ...alwaysApplyRules],
1090
- }),
1091
- );
1092
- internalRouter.register(new PiProtocolHandler());
1093
- internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager }));
1094
- internalRouter.register(new McpProtocolHandler({ getMcpManager: () => mcpManager }));
1095
- toolSession.internalRouter = internalRouter;
1069
+ if (!options.parentTaskPrefix) {
1070
+ setActiveSkills(skills);
1071
+ setActiveRules([...rulebookRules, ...alwaysApplyRules]);
1072
+ if (asyncJobManager) AsyncJobManager.setInstance(asyncJobManager);
1073
+ }
1074
+ if (options.localProtocolOptions) {
1075
+ LocalProtocolHandler.setOverride(options.localProtocolOptions);
1076
+ }
1096
1077
  toolSession.getArtifactsDir = getArtifactsDir;
1097
1078
  toolSession.agentOutputManager = new AgentOutputManager(
1098
1079
  getArtifactsDir,
@@ -1141,7 +1122,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1141
1122
  customTools.push(...mcpResult.tools.map(loaded => loaded.tool));
1142
1123
  }
1143
1124
  }
1144
- toolSession.mcpManager = mcpManager;
1125
+ // Only top-level sessions own the global MCPManager. Subagents already
1126
+ // receive the parent's manager via `options.mcpManager`, and reassigning
1127
+ // the singleton to the same value is a no-op \u2014 keep the gate explicit
1128
+ // to mirror the AsyncJobManager ownership rule.
1129
+ if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
1145
1130
 
1146
1131
  // Add image tools when the active model or configured image providers can generate images.
1147
1132
  const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
@@ -1552,6 +1537,21 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1552
1537
  });
1553
1538
  }
1554
1539
 
1540
+ // Pre-register in the global agent registry BEFORE building the system prompt,
1541
+ // so that subagents launched in the same parallel batch can see each other in
1542
+ // their initial `# IRC Peers` block (rendered inside `rebuildSystemPrompt`).
1543
+ // The session reference is attached after construction below.
1544
+ agentRegistry.register({
1545
+ id: resolvedAgentId,
1546
+ displayName: resolvedAgentDisplayName,
1547
+ kind: (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main",
1548
+ parentId: options.parentTaskPrefix,
1549
+ session: null,
1550
+ sessionFile: sessionManager.getSessionFile() ?? null,
1551
+ status: "running",
1552
+ });
1553
+ hasRegistered = true;
1554
+
1555
1555
  const { systemPrompt } = await logger.time(
1556
1556
  "buildSystemPrompt",
1557
1557
  rebuildSystemPrompt,
@@ -1708,6 +1708,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1708
1708
  sessionManager,
1709
1709
  settings,
1710
1710
  evalKernelOwnerId,
1711
+ // Defined only for top-level sessions (creation is gated above).
1712
+ // AgentSession uses this to decide whether it may dispose the global
1713
+ // AsyncJobManager on teardown; subagents inherit the parent's and
1714
+ // **MUST NOT** tear it down.
1715
+ ownedAsyncJobManager: asyncJobManager,
1711
1716
  scopedModels: options.scopedModels,
1712
1717
  promptTemplates,
1713
1718
  slashCommands,
@@ -1744,24 +1749,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1744
1749
  defaultSelectedMCPServerNames: [...discoveryDefaultServers],
1745
1750
  ttsrManager,
1746
1751
  obfuscator,
1747
- asyncJobManager,
1748
1752
  agentId: resolvedAgentId,
1749
1753
  agentRegistry,
1750
1754
  providerSessionId: options.providerSessionId,
1751
1755
  });
1752
1756
  hasSession = true;
1753
1757
 
1754
- // Register this session in the global agent registry so other agents can
1755
- // address it via the irc tool. Wrap dispose to unregister on teardown.
1756
- agentRegistry.register({
1757
- id: resolvedAgentId,
1758
- displayName: resolvedAgentDisplayName,
1759
- kind: (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main",
1760
- parentId: options.parentTaskPrefix,
1761
- session,
1762
- sessionFile: sessionManager.getSessionFile() ?? null,
1763
- status: "running",
1764
- });
1758
+ // Attach the live session to the pre-registered ref so peers can route IRC
1759
+ // messages here. Refresh sessionFile in case it was unavailable at pre-register
1760
+ // time. The dispose wrapper below unregisters on teardown.
1761
+ agentRegistry.attachSession(resolvedAgentId, session, sessionManager.getSessionFile() ?? null);
1765
1762
  {
1766
1763
  const originalDispose = session.dispose.bind(session);
1767
1764
  session.dispose = async () => {
@@ -1908,6 +1905,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1908
1905
  if (hasSession) {
1909
1906
  await session.dispose();
1910
1907
  } else {
1908
+ if (hasRegistered) agentRegistry.unregister(resolvedAgentId);
1911
1909
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
1912
1910
  }
1913
1911
  } catch (cleanupError) {
@@ -55,7 +55,7 @@ import {
55
55
  } from "@oh-my-pi/pi-ai";
56
56
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
57
57
  import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
58
- import type { AsyncJob, AsyncJobManager } from "../async";
58
+ import { type AsyncJob, AsyncJobManager } from "../async";
59
59
  import type { Rule } from "../capability/rule";
60
60
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
61
61
  import {
@@ -225,8 +225,6 @@ export interface AgentSessionConfig {
225
225
  agent: Agent;
226
226
  sessionManager: SessionManager;
227
227
  settings: Settings;
228
- /** Async background jobs launched by tools */
229
- asyncJobManager?: AsyncJobManager;
230
228
  /** Models to cycle through with Ctrl+P (from --models flag) */
231
229
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
232
230
  /** Initial session thinking selector. */
@@ -285,6 +283,12 @@ export interface AgentSessionConfig {
285
283
  obfuscator?: SecretObfuscator;
286
284
  /** Logical owner for retained Python kernels created by this session. */
287
285
  evalKernelOwnerId?: string;
286
+ /**
287
+ * AsyncJobManager that this session installed as the process-global instance.
288
+ * Only set for top-level sessions; subagents inherit the parent's manager and
289
+ * **MUST NOT** dispose it on their own teardown.
290
+ */
291
+ ownedAsyncJobManager?: AsyncJobManager;
288
292
  /** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
289
293
  agentId?: string;
290
294
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
@@ -507,7 +511,6 @@ export class AgentSession {
507
511
 
508
512
  readonly configWarnings: string[] = [];
509
513
 
510
- #asyncJobManager: AsyncJobManager | undefined = undefined;
511
514
  #scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
512
515
  #thinkingLevel: ThinkingLevel | undefined;
513
516
  #promptTemplates: PromptTemplate[];
@@ -558,6 +561,11 @@ export class AgentSession {
558
561
  // Python execution state
559
562
  #evalAbortControllers = new Set<AbortController>();
560
563
  #evalKernelOwnerId: string;
564
+ /**
565
+ * AsyncJobManager owned by this session (top-level only). Subagents leave
566
+ * this undefined and **MUST NOT** dispose the global instance on teardown.
567
+ */
568
+ readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
561
569
  #pendingPythonMessages: PythonExecutionMessage[] = [];
562
570
  #activeEvalExecutions = new Set<Promise<unknown>>();
563
571
  #evalExecutionDisposing = false;
@@ -704,8 +712,8 @@ export class AgentSession {
704
712
  this.sessionManager = config.sessionManager;
705
713
  this.settings = config.settings;
706
714
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
707
- this.#asyncJobManager = config.asyncJobManager;
708
715
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
716
+ this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
709
717
  this.#scopedModels = config.scopedModels ?? [];
710
718
  this.#thinkingLevel = config.thinkingLevel;
711
719
  this.#promptTemplates = config.promptTemplates ?? [];
@@ -848,15 +856,16 @@ export class AgentSession {
848
856
  }
849
857
 
850
858
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
851
- if (!this.#asyncJobManager) return null;
852
- const running = this.#asyncJobManager.getRunningJobs().map(job => ({
859
+ const manager = AsyncJobManager.instance();
860
+ if (!manager) return null;
861
+ const running = manager.getRunningJobs().map(job => ({
853
862
  id: job.id,
854
863
  type: job.type,
855
864
  status: job.status,
856
865
  label: job.label,
857
866
  startTime: job.startTime,
858
867
  }));
859
- const recent = this.#asyncJobManager.getRecentJobs(options?.recentLimit ?? 5).map(job => ({
868
+ const recent = manager.getRecentJobs(options?.recentLimit ?? 5).map(job => ({
860
869
  id: job.id,
861
870
  type: job.type,
862
871
  status: job.status,
@@ -866,6 +875,17 @@ export class AgentSession {
866
875
  return { running, recent };
867
876
  }
868
877
 
878
+ /**
879
+ * Cancel async jobs registered by *this* agent only. Used by lifecycle
880
+ * transitions (newSession, switchSession, handoff, dispose) so a subagent
881
+ * cleans up its own background work without touching its parent's jobs.
882
+ * No-op when no manager is installed or this session has no agent id.
883
+ */
884
+ #cancelOwnAsyncJobs(): void {
885
+ if (!this.#agentId) return;
886
+ AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
887
+ }
888
+
869
889
  // =========================================================================
870
890
  // Event Subscription
871
891
  // =========================================================================
@@ -1739,7 +1759,6 @@ export class AgentSession {
1739
1759
  }
1740
1760
 
1741
1761
  #preCacheStreamingEditFile(event: AgentEvent): void {
1742
- if (!this.settings.get("edit.streamingAbort")) return;
1743
1762
  if (this.#streamingEditAbortTriggered) return;
1744
1763
  if (event.type !== "message_update") return;
1745
1764
 
@@ -1755,6 +1774,9 @@ export class AgentSession {
1755
1774
  const streamingEdit = this.#getStreamingEditToolCall(event);
1756
1775
  if (!streamingEdit) return;
1757
1776
 
1777
+ // The auto-generated guard runs unconditionally: editing a generated file
1778
+ // is never the user's intent, and the cost of a false-positive abort is one
1779
+ // wasted turn vs. silently corrupting a regenerated source.
1758
1780
  const shouldCheckAutoGenerated =
1759
1781
  !streamingEdit.toolCall.id || !this.#streamingEditPrecheckedToolCallIds.has(streamingEdit.toolCall.id);
1760
1782
  if (shouldCheckAutoGenerated) {
@@ -1768,7 +1790,12 @@ export class AgentSession {
1768
1790
  );
1769
1791
  }
1770
1792
 
1771
- this.#ensureFileCache(streamingEdit.resolvedPath);
1793
+ // File-cache priming feeds #maybeAbortStreamingEdit's removed-lines check,
1794
+ // which is the optional patch-preview verification gated by
1795
+ // edit.streamingAbort. Skip the read when the setting is off.
1796
+ if (this.settings.get("edit.streamingAbort")) {
1797
+ this.#ensureFileCache(streamingEdit.resolvedPath);
1798
+ }
1772
1799
  }
1773
1800
 
1774
1801
  #ensureFileCache(resolvedPath: string): void {
@@ -2149,10 +2176,21 @@ export class AgentSession {
2149
2176
  }
2150
2177
  await this.#cancelPostPromptTasks();
2151
2178
  this.#clearTodoClearTimers();
2152
- const drained = await this.#asyncJobManager?.dispose({ timeoutMs: 3_000 });
2153
- const deliveryState = this.#asyncJobManager?.getDeliveryState();
2154
- if (drained === false && deliveryState) {
2155
- logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
2179
+ // Cancel jobs this agent registered so a subagent's teardown doesn't
2180
+ // leak its background bash/task work into the parent's manager. Only
2181
+ // the session that owns the manager goes on to dispose it (which itself
2182
+ // nukes any leftover jobs and pending deliveries).
2183
+ this.#cancelOwnAsyncJobs();
2184
+ const ownedAsyncManager = this.#ownedAsyncJobManager;
2185
+ if (ownedAsyncManager) {
2186
+ const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
2187
+ const deliveryState = ownedAsyncManager.getDeliveryState();
2188
+ if (drained === false && deliveryState) {
2189
+ logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
2190
+ }
2191
+ if (AsyncJobManager.instance() === ownedAsyncManager) {
2192
+ AsyncJobManager.setInstance(undefined);
2193
+ }
2156
2194
  }
2157
2195
  const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
2158
2196
  if (!pythonExecutionsSettled) {
@@ -3948,7 +3986,7 @@ export class AgentSession {
3948
3986
 
3949
3987
  this.#disconnectFromAgent();
3950
3988
  await this.abort();
3951
- this.#asyncJobManager?.cancelAll();
3989
+ this.#cancelOwnAsyncJobs();
3952
3990
  this.#closeAllProviderSessions("new session");
3953
3991
  this.agent.reset();
3954
3992
  if (options?.drop && previousSessionFile) {
@@ -4756,7 +4794,7 @@ export class AgentSession {
4756
4794
  // Start a new session
4757
4795
  const previousSessionFile = this.sessionFile;
4758
4796
  await this.sessionManager.flush();
4759
- this.#asyncJobManager?.cancelAll();
4797
+ this.#cancelOwnAsyncJobs();
4760
4798
  await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
4761
4799
  this.agent.reset();
4762
4800
  this.#syncAgentSessionId();
@@ -6523,7 +6561,6 @@ export class AgentSession {
6523
6561
  sessionId,
6524
6562
  kernelOwnerId: this.#evalKernelOwnerId,
6525
6563
  kernelMode: this.settings.get("python.kernelMode"),
6526
- useSharedGateway: this.settings.get("python.sharedGateway"),
6527
6564
  onChunk,
6528
6565
  signal: abortController.signal,
6529
6566
  });
@@ -6675,7 +6712,7 @@ export class AgentSession {
6675
6712
  const incomingRecord: CustomMessage = {
6676
6713
  role: "custom",
6677
6714
  customType: "irc:incoming",
6678
- content: `[IRC \`${args.from}\` \u2192 you]\n\n${args.message}`,
6715
+ content: `[IRC \`${args.from}\` you]\n\n${args.message}`,
6679
6716
  display: true,
6680
6717
  details: { from: args.from, message: args.message },
6681
6718
  attribution: "agent",
@@ -6707,7 +6744,7 @@ export class AgentSession {
6707
6744
  const replyRecord: CustomMessage = {
6708
6745
  role: "custom",
6709
6746
  customType: "irc:autoreply",
6710
- content: `[IRC you \u2192 \`${args.from}\` (auto)]\n\n${replyText}`,
6747
+ content: `[IRC you \`${args.from}\` (auto)]\n\n${replyText}`,
6711
6748
  display: true,
6712
6749
  details: { to: args.from, reply: replyText },
6713
6750
  attribution: "agent",
@@ -6746,7 +6783,7 @@ export class AgentSession {
6746
6783
  const mainRef = registry.get(MAIN_AGENT_ID);
6747
6784
  const mainSession = mainRef?.session;
6748
6785
  if (!mainSession || mainSession === this) return;
6749
- const arrow = args.kind === "reply" ? "\u2192 (auto)" : "\u2192";
6786
+ const arrow = args.kind === "reply" ? " (auto)" : "";
6750
6787
  const relayRecord: CustomMessage = {
6751
6788
  role: "custom",
6752
6789
  customType: "irc:relay",
@@ -7149,7 +7186,7 @@ export class AgentSession {
7149
7186
 
7150
7187
  // Flush pending writes before branching
7151
7188
  await this.sessionManager.flush();
7152
- this.#asyncJobManager?.cancelAll();
7189
+ this.#cancelOwnAsyncJobs();
7153
7190
 
7154
7191
  if (!selectedEntry.parentId) {
7155
7192
  await this.sessionManager.newSession({ parentSession: previousSessionFile });
@@ -12,6 +12,10 @@ import * as path from "node:path";
12
12
  *
13
13
  * Artifacts are stored with sequential IDs in the session's artifact directory.
14
14
  * The directory is created lazily on first write.
15
+ *
16
+ * Subagents do not own their own `ArtifactManager`. The parent's instance is
17
+ * adopted via `SessionManager.adoptArtifactManager`, so the whole parent +
18
+ * subagent tree shares one ID space and one directory.
15
19
  */
16
20
  export class ArtifactManager {
17
21
  #nextId = 0;
@@ -20,11 +24,10 @@ export class ArtifactManager {
20
24
  #initialized = false;
21
25
 
22
26
  /**
23
- * @param sessionFile Path to the session .jsonl file
27
+ * @param dir Directory that will hold artifact files. Created lazily on first save.
24
28
  */
25
- constructor(sessionFile: string) {
26
- // Artifact directory is session file path without .jsonl extension
27
- this.#dir = sessionFile.slice(0, -6);
29
+ constructor(dir: string) {
30
+ this.#dir = dir;
28
31
  }
29
32
 
30
33
  /**