@oh-my-pi/pi-coding-agent 15.10.11 → 15.11.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 (217) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/cli.js +5790 -5731
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/cli-commands.d.ts +12 -0
  7. package/dist/types/commands/launch.d.ts +4 -0
  8. package/dist/types/config/api-key-resolver.d.ts +3 -0
  9. package/dist/types/config/keybindings.d.ts +6 -1
  10. package/dist/types/config/model-registry.d.ts +1 -0
  11. package/dist/types/config/model-resolver.d.ts +18 -0
  12. package/dist/types/config/settings-schema.d.ts +85 -34
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  15. package/dist/types/eval/py/executor.d.ts +5 -0
  16. package/dist/types/eval/py/kernel.d.ts +6 -1
  17. package/dist/types/eval/py/runtime.d.ts +9 -0
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/export/html/template.generated.d.ts +1 -1
  20. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/extensibility/shared-events.d.ts +2 -2
  24. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  25. package/dist/types/internal-urls/index.d.ts +1 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/irc/bus.d.ts +66 -0
  28. package/dist/types/memory-backend/index.d.ts +1 -0
  29. package/dist/types/memory-backend/runtime.d.ts +4 -0
  30. package/dist/types/memory-backend/types.d.ts +66 -1
  31. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  32. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  34. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  35. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  36. package/dist/types/modes/components/welcome.d.ts +3 -9
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  38. package/dist/types/modes/index.d.ts +3 -3
  39. package/dist/types/modes/interactive-mode.d.ts +10 -4
  40. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  41. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  42. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  43. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  44. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  45. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  46. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  47. package/dist/types/modes/theme/theme.d.ts +2 -1
  48. package/dist/types/modes/types.d.ts +5 -2
  49. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  50. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  51. package/dist/types/registry/agent-registry.d.ts +16 -5
  52. package/dist/types/secrets/index.d.ts +1 -1
  53. package/dist/types/secrets/obfuscator.d.ts +8 -2
  54. package/dist/types/session/agent-session.d.ts +49 -32
  55. package/dist/types/session/messages.d.ts +2 -4
  56. package/dist/types/session/session-history-format.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +21 -3
  58. package/dist/types/session/streaming-output.d.ts +46 -0
  59. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  60. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  61. package/dist/types/slash-commands/types.d.ts +1 -1
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +12 -2
  64. package/dist/types/task/index.d.ts +13 -6
  65. package/dist/types/task/output-manager.d.ts +0 -7
  66. package/dist/types/task/repair-args.d.ts +8 -7
  67. package/dist/types/task/types.d.ts +63 -51
  68. package/dist/types/thinking.d.ts +4 -0
  69. package/dist/types/tiny/title-client.d.ts +11 -0
  70. package/dist/types/tiny/title-protocol.d.ts +1 -0
  71. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  72. package/dist/types/tools/find.d.ts +0 -11
  73. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  74. package/dist/types/tools/index.d.ts +7 -3
  75. package/dist/types/tools/irc.d.ts +76 -38
  76. package/dist/types/tools/job.d.ts +7 -1
  77. package/dist/types/utils/git.d.ts +15 -2
  78. package/dist/types/utils/title-generator.d.ts +3 -2
  79. package/examples/extensions/with-deps/package.json +1 -0
  80. package/package.json +11 -10
  81. package/scripts/bundle-dist.ts +28 -19
  82. package/src/async/index.ts +0 -1
  83. package/src/auto-thinking/classifier.ts +1 -0
  84. package/src/cli/args.ts +3 -0
  85. package/src/cli/gallery-cli.ts +1 -1
  86. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  87. package/src/cli/gallery-fixtures/types.ts +5 -0
  88. package/src/cli-commands.ts +29 -0
  89. package/src/cli.ts +28 -15
  90. package/src/commands/launch.ts +4 -0
  91. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  92. package/src/commit/model-selection.ts +3 -2
  93. package/src/config/api-key-resolver.ts +8 -6
  94. package/src/config/keybindings.ts +6 -1
  95. package/src/config/model-registry.ts +97 -30
  96. package/src/config/model-resolver.ts +60 -0
  97. package/src/config/settings-schema.ts +99 -55
  98. package/src/config/settings.ts +68 -3
  99. package/src/edit/hashline/execute.ts +39 -2
  100. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  101. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  102. package/src/eval/agent-bridge.ts +3 -16
  103. package/src/eval/completion-bridge.ts +1 -0
  104. package/src/eval/js/shared/prelude.txt +1 -1
  105. package/src/eval/py/executor.ts +29 -7
  106. package/src/eval/py/index.ts +6 -1
  107. package/src/eval/py/kernel.ts +31 -11
  108. package/src/eval/py/prelude.py +5 -6
  109. package/src/eval/py/runtime.ts +37 -0
  110. package/src/exec/bash-executor.ts +82 -3
  111. package/src/export/html/template.generated.ts +1 -1
  112. package/src/export/html/template.js +38 -13
  113. package/src/extensibility/custom-tools/types.ts +2 -2
  114. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  115. package/src/extensibility/extensions/runner.ts +6 -1
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/shared-events.ts +2 -2
  118. package/src/hindsight/bank.ts +17 -2
  119. package/src/internal-urls/docs-index.generated.ts +11 -11
  120. package/src/internal-urls/history-protocol.ts +113 -0
  121. package/src/internal-urls/index.ts +1 -0
  122. package/src/internal-urls/router.ts +3 -1
  123. package/src/internal-urls/types.ts +1 -1
  124. package/src/irc/bus.ts +292 -0
  125. package/src/main.ts +26 -66
  126. package/src/memories/index.ts +2 -0
  127. package/src/memory-backend/index.ts +1 -0
  128. package/src/memory-backend/local-backend.ts +9 -0
  129. package/src/memory-backend/off-backend.ts +9 -0
  130. package/src/memory-backend/runtime.ts +66 -0
  131. package/src/memory-backend/types.ts +81 -1
  132. package/src/mnemopi/backend.ts +151 -4
  133. package/src/modes/acp/acp-agent.ts +119 -11
  134. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  135. package/src/modes/components/assistant-message.ts +19 -21
  136. package/src/modes/components/compaction-summary-message.ts +68 -32
  137. package/src/modes/components/custom-editor.ts +10 -0
  138. package/src/modes/components/footer.ts +3 -1
  139. package/src/modes/components/status-line/component.ts +118 -34
  140. package/src/modes/components/tool-execution.ts +31 -1
  141. package/src/modes/components/ttsr-notification.ts +72 -30
  142. package/src/modes/components/welcome.ts +9 -33
  143. package/src/modes/controllers/command-controller.ts +1 -1
  144. package/src/modes/controllers/event-controller.ts +65 -0
  145. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  146. package/src/modes/controllers/input-controller.ts +19 -2
  147. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  148. package/src/modes/controllers/selector-controller.ts +21 -17
  149. package/src/modes/index.ts +3 -21
  150. package/src/modes/interactive-mode.ts +47 -22
  151. package/src/modes/oauth-manual-input.ts +30 -3
  152. package/src/modes/rpc/rpc-client.ts +154 -3
  153. package/src/modes/rpc/rpc-mode.ts +97 -12
  154. package/src/modes/rpc/rpc-subagents.ts +265 -0
  155. package/src/modes/rpc/rpc-types.ts +81 -1
  156. package/src/modes/setup-wizard/index.ts +12 -2
  157. package/src/modes/setup-wizard/lazy.ts +16 -0
  158. package/src/modes/theme/theme.ts +18 -5
  159. package/src/modes/types.ts +5 -5
  160. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  161. package/src/modes/utils/ui-helpers.ts +51 -49
  162. package/src/prompts/system/irc-incoming.md +3 -4
  163. package/src/prompts/system/orchestrate-notice.md +2 -2
  164. package/src/prompts/system/subagent-system-prompt.md +0 -5
  165. package/src/prompts/system/system-prompt.md +1 -0
  166. package/src/prompts/system/workflow-notice.md +2 -2
  167. package/src/prompts/tools/eval.md +3 -3
  168. package/src/prompts/tools/irc.md +29 -19
  169. package/src/prompts/tools/read.md +2 -2
  170. package/src/prompts/tools/task-summary.md +5 -16
  171. package/src/prompts/tools/task.md +38 -29
  172. package/src/registry/agent-lifecycle.ts +218 -0
  173. package/src/registry/agent-registry.ts +16 -5
  174. package/src/sdk.ts +37 -10
  175. package/src/secrets/index.ts +8 -1
  176. package/src/secrets/obfuscator.ts +39 -18
  177. package/src/session/agent-session.ts +422 -291
  178. package/src/session/messages.ts +11 -78
  179. package/src/session/session-history-format.ts +246 -0
  180. package/src/session/session-manager.ts +59 -5
  181. package/src/session/streaming-output.ts +226 -10
  182. package/src/slash-commands/acp-builtins.ts +24 -0
  183. package/src/slash-commands/builtin-registry.ts +20 -0
  184. package/src/slash-commands/types.ts +1 -1
  185. package/src/system-prompt.ts +14 -0
  186. package/src/task/executor.ts +851 -461
  187. package/src/task/index.ts +721 -796
  188. package/src/task/output-manager.ts +0 -11
  189. package/src/task/render.ts +148 -63
  190. package/src/task/repair-args.ts +21 -9
  191. package/src/task/types.ts +82 -66
  192. package/src/thinking.ts +7 -0
  193. package/src/tiny/title-client.ts +34 -5
  194. package/src/tiny/title-protocol.ts +1 -1
  195. package/src/tiny/worker.ts +6 -4
  196. package/src/tools/ask.ts +4 -2
  197. package/src/tools/bash.ts +61 -10
  198. package/src/tools/browser/tab-worker.ts +26 -7
  199. package/src/tools/browser.ts +28 -1
  200. package/src/tools/find.ts +2 -27
  201. package/src/tools/grouped-file-output.ts +1 -118
  202. package/src/tools/image-gen.ts +11 -4
  203. package/src/tools/index.ts +17 -13
  204. package/src/tools/inspect-image.ts +1 -0
  205. package/src/tools/irc.ts +596 -171
  206. package/src/tools/job.ts +41 -7
  207. package/src/tools/read.ts +57 -1
  208. package/src/tools/renderers.ts +2 -0
  209. package/src/tools/resolve.ts +4 -1
  210. package/src/utils/commit-message-generator.ts +1 -0
  211. package/src/utils/git.ts +267 -13
  212. package/src/utils/title-generator.ts +24 -5
  213. package/dist/types/async/support.d.ts +0 -2
  214. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  215. package/dist/types/task/simple-mode.d.ts +0 -8
  216. package/src/async/support.ts +0 -5
  217. package/src/task/simple-mode.ts +0 -27
@@ -64,6 +64,8 @@ export interface SettingsOptions {
64
64
  inMemory?: boolean;
65
65
  /** Initial overrides */
66
66
  overrides?: Partial<Record<SettingPath, unknown>>;
67
+ /** Extra config.yml-style overlays loaded after global/project settings */
68
+ configFiles?: string[];
67
69
  }
68
70
 
69
71
  // ═══════════════════════════════════════════════════════════════════════════
@@ -116,10 +118,12 @@ type PathScopedStringArrayEntry = {
116
118
  providers?: unknown;
117
119
  };
118
120
 
121
+ function expandTilde(p: string): string {
122
+ return p === "~" ? os.homedir() : p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
123
+ }
124
+
119
125
  function normalizePathPrefix(prefix: string): string {
120
- const expanded =
121
- prefix === "~" ? os.homedir() : prefix.startsWith("~/") ? path.join(os.homedir(), prefix.slice(2)) : prefix;
122
- return path.resolve(expanded);
126
+ return path.resolve(expandTilde(prefix));
123
127
  }
124
128
 
125
129
  function pathMatchesPrefix(cwd: string, prefix: string): boolean {
@@ -193,10 +197,13 @@ export class Settings {
193
197
  #agentDir: string;
194
198
  #storage: AgentStorage | null = null;
195
199
 
200
+ #configFiles: string[] = [];
196
201
  /** Global settings from config.yml */
197
202
  #global: RawSettings = {};
198
203
  /** Project settings from .claude/settings.yml etc */
199
204
  #project: RawSettings = {};
205
+ /** Extra config.yml-style overlays passed by CLI */
206
+ #configOverlay: RawSettings = {};
200
207
  /** Runtime overrides (not persisted) */
201
208
  #overrides: RawSettings = {};
202
209
  /** Merged view (global + project + overrides) */
@@ -221,6 +228,7 @@ export class Settings {
221
228
  this.#cwd = path.normalize(options.cwd ?? getProjectDir());
222
229
  this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
223
230
  this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
231
+ this.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
224
232
  this.#persist = !options.inMemory;
225
233
 
226
234
  if (options.overrides) {
@@ -256,6 +264,7 @@ export class Settings {
256
264
  },
257
265
  error => {
258
266
  globalInstance = null;
267
+ globalInstancePromise = null;
259
268
  clearBoundSettingsMethods();
260
269
  throw error;
261
270
  },
@@ -303,6 +312,14 @@ export class Settings {
303
312
  return resolved as SettingValue<P>;
304
313
  }
305
314
 
315
+ /**
316
+ * Whether `path` has an explicitly configured value (global config, project
317
+ * config, or runtime override) rather than falling back to the schema default.
318
+ */
319
+ isConfigured(path: SettingPath): boolean {
320
+ return getByPath(this.#merged, SETTING_PATH_SEGMENTS[path]) !== undefined;
321
+ }
322
+
306
323
  /**
307
324
  * Set a setting value (sync).
308
325
  * Updates global settings and queues a background save.
@@ -386,6 +403,8 @@ export class Settings {
386
403
  cloned.#storage = this.#storage;
387
404
  cloned.#global = structuredClone(this.#global);
388
405
  cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
406
+ cloned.#configFiles = [...this.#configFiles];
407
+ cloned.#configOverlay = structuredClone(this.#configOverlay);
389
408
  cloned.#overrides = structuredClone(this.#overrides);
390
409
  cloned.#rebuildMerged();
391
410
  cloned.#fireAllHooks();
@@ -557,6 +576,7 @@ export class Settings {
557
576
  }
558
577
 
559
578
  this.#project = await projectPromise;
579
+ this.#configOverlay = await this.#loadConfigOverlays();
560
580
 
561
581
  // Build merged view (global → project → overrides; project wins over global)
562
582
  this.#rebuildMerged();
@@ -594,6 +614,43 @@ export class Settings {
594
614
  }
595
615
  }
596
616
 
617
+ async #loadConfigOverlays(): Promise<RawSettings> {
618
+ let merged: RawSettings = {};
619
+ for (const filePath of this.#configFiles) {
620
+ merged = this.#deepMerge(merged, await this.#loadOverlayYaml(filePath));
621
+ }
622
+ return merged;
623
+ }
624
+
625
+ /**
626
+ * Strict loader for explicit `--config` overlays: unlike `#loadYaml`,
627
+ * missing or malformed files are hard errors so a typo'd path cannot
628
+ * silently fall back to the persistent settings.
629
+ */
630
+ async #loadOverlayYaml(filePath: string): Promise<RawSettings> {
631
+ let content: string;
632
+ try {
633
+ content = await Bun.file(filePath).text();
634
+ } catch (error) {
635
+ throw new Error(
636
+ isEnoent(error)
637
+ ? `Config overlay not found: ${filePath}`
638
+ : `Failed to read config overlay ${filePath}: ${String(error)}`,
639
+ );
640
+ }
641
+ let parsed: unknown;
642
+ try {
643
+ parsed = YAML.parse(content);
644
+ } catch (error) {
645
+ throw new Error(`Failed to parse config overlay ${filePath}: ${String(error)}`);
646
+ }
647
+ if (parsed === null || parsed === undefined) return {};
648
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
649
+ throw new Error(`Config overlay must be a YAML mapping: ${filePath}`);
650
+ }
651
+ return this.#migrateRawSettings(parsed as RawSettings);
652
+ }
653
+
597
654
  async #migrateFromLegacy(): Promise<void> {
598
655
  if (!this.#configPath) return;
599
656
 
@@ -688,6 +745,13 @@ export class Settings {
688
745
  delete isolationObj.enabled;
689
746
  }
690
747
 
748
+ // task.simple: removed — the task tool no longer accepts a per-call
749
+ // schema (workflows drive structured output via eval agent()) and the
750
+ // batch/context shape is gated by task.batch instead.
751
+ if (taskObj && "simple" in taskObj) {
752
+ delete taskObj.simple;
753
+ }
754
+
691
755
  // task.isolation.mode: legacy values from before the pi-iso PAL refactor.
692
756
  // `worktree` was git worktree → now lives under `rcopy`. `fuse-overlay`
693
757
  // and `fuse-projfs` are now the platform-named `overlayfs` / `projfs`
@@ -898,6 +962,7 @@ export class Settings {
898
962
 
899
963
  #rebuildMerged(): void {
900
964
  this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project);
965
+ this.#merged = this.#deepMerge(this.#merged, this.#configOverlay);
901
966
  this.#merged = this.#deepMerge(this.#merged, this.#overrides);
902
967
  this.#resolvedCache.clear();
903
968
  }
@@ -23,11 +23,13 @@ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
23
23
  import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
24
24
  import type { ToolSession } from "../../tools";
25
25
  import { outputMeta } from "../../tools/output-meta";
26
+ import { ToolError } from "../../tools/tool-errors";
26
27
  import { generateDiffString } from "../diff";
27
28
  import { getFileSnapshotStore } from "../file-snapshot-store";
28
29
  import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
29
30
  import { nativeBlockResolver } from "./block-resolver";
30
31
  import { HashlineFilesystem } from "./filesystem";
32
+ import { hashPatchInput, NOOP_HARD_LIMIT, recordNoopEdit, resetNoopEdit } from "./noop-loop-guard";
31
33
  import { type HashlineParams, hashlineEditParamsSchema } from "./params";
32
34
 
33
35
  export interface ExecuteHashlineSingleOptions {
@@ -54,6 +56,24 @@ function noChangeDiagnostic(path: string): string {
54
56
  );
55
57
  }
56
58
 
59
+ /**
60
+ * Escalated diagnostic surfaced once the same payload has no-op'd
61
+ * {@link NOOP_HARD_LIMIT} times in a row on the same canonical path. Thrown as
62
+ * a {@link ToolError} so the agent loop sees a tool *failure* — empirically
63
+ * far more effective at breaking a no-op edit loop than the soft hint alone
64
+ * (issue #2081 saw 182 byte-identical no-op results in 205 calls before the
65
+ * user aborted).
66
+ */
67
+ function noChangeLoopDiagnostic(path: string, count: number): string {
68
+ return (
69
+ `STOP. Edits to ${path} have been a byte-identical no-op ${count} times in a row — ` +
70
+ `the patch body matches the file at the targeted lines and the soft hint did not break the cycle. ` +
71
+ `Cease re-issuing this payload. Either the intended change is already on disk (move on), ` +
72
+ `or your anchor is wrong (re-read the file with \`read\` to observe the current line numbers and ` +
73
+ `tag, then author a different edit). This exact payload will keep being rejected until it changes.`
74
+ );
75
+ }
76
+
57
77
  function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
58
78
  const seen = new Map<string, string>();
59
79
  for (const entry of prepared) {
@@ -156,13 +176,19 @@ export async function executeHashlineSingle(
156
176
  const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver });
157
177
 
158
178
  // Single-section fast path: prepare, commit, render.
179
+ const inputHash = hashPatchInput(options.input);
159
180
  if (patch.sections.length === 1) {
160
181
  fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
161
182
  const prepared = await patcher.prepare(patch.sections[0]);
162
183
  const sectionResult = await patcher.commit(prepared);
163
184
  if (sectionResult.op === "noop") {
185
+ const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
186
+ if (escalate) {
187
+ throw new ToolError(noChangeLoopDiagnostic(sectionResult.path, count));
188
+ }
164
189
  return renderSection(sectionResult, undefined).toolResult;
165
190
  }
191
+ resetNoopEdit(options.session, sectionResult.canonicalPath);
166
192
  return renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)).toolResult;
167
193
  }
168
194
 
@@ -172,7 +198,12 @@ export async function executeHashlineSingle(
172
198
  for (const section of patch.sections) prepared.push(await patcher.prepare(section));
173
199
  assertUniqueCanonicalPaths(prepared);
174
200
  for (const entry of prepared) {
175
- if (entry.isNoop) throw new Error(noChangeDiagnostic(entry.section.path));
201
+ if (entry.isNoop) {
202
+ const { count, escalate } = recordNoopEdit(options.session, entry.canonicalPath, inputHash);
203
+ throw escalate
204
+ ? new ToolError(noChangeLoopDiagnostic(entry.section.path, count))
205
+ : new ToolError(noChangeDiagnostic(entry.section.path));
206
+ }
176
207
  }
177
208
  // Then commit each one, narrowing the LSP batch flush flag to the final
178
209
  // section only. A no-op apply mid-batch is treated as a hard failure —
@@ -182,7 +213,13 @@ export async function executeHashlineSingle(
182
213
  const isLast = i === prepared.length - 1;
183
214
  fs.setBatchRequest(narrowBatchRequest(options.batchRequest, isLast));
184
215
  const sectionResult = await patcher.commit(prepared[i]);
185
- if (sectionResult.op === "noop") throw new Error(noChangeDiagnostic(sectionResult.path));
216
+ if (sectionResult.op === "noop") {
217
+ const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
218
+ throw escalate
219
+ ? new ToolError(noChangeLoopDiagnostic(sectionResult.path, count))
220
+ : new ToolError(noChangeDiagnostic(sectionResult.path));
221
+ }
222
+ resetNoopEdit(options.session, sectionResult.canonicalPath);
186
223
  rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path)));
187
224
  }
188
225
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Per-session guard against subagents looping on byte-identical no-op edits.
3
+ *
4
+ * A hashline patch can apply cleanly yet produce no change when the body rows
5
+ * are already byte-identical to the targeted lines. {@link executeHashlineSingle}
6
+ * surfaces a soft hint ("re-read the file before issuing another edit"), but in
7
+ * the wild some models ignore the hint and keep re-issuing the same bytes
8
+ * (issue #2081 captured 182 such repeats in 205 calls before the user aborted).
9
+ *
10
+ * This module tracks consecutive byte-identical no-op edits per canonical file
11
+ * path within a single session. Once the same payload no-ops {@link NOOP_HARD_LIMIT}
12
+ * times in a row the caller is expected to escalate from a soft text result to
13
+ * a thrown {@link ToolError} so the agent loop sees a tool *failure* — empirically
14
+ * far more effective at breaking the cycle than the soft hint alone.
15
+ *
16
+ * A successful (non-noop) commit for a path resets that path's counter; a
17
+ * different payload on the same path also resets it because the body hash
18
+ * changed, which is a sign of model progress and deserves another soft hint.
19
+ */
20
+
21
+ interface NoopLoopEntry {
22
+ /** Hash of the most recent input that no-op'd on this canonical path. */
23
+ hash: string;
24
+ /** Consecutive no-op count for the same `hash` on this path. */
25
+ count: number;
26
+ }
27
+
28
+ /** Cross-session-safe state slot held on the `ToolSession`. */
29
+ export interface NoopLoopGuard {
30
+ entries: Map<string, NoopLoopEntry>;
31
+ }
32
+
33
+ /**
34
+ * After this many consecutive byte-identical no-op edits on the same path,
35
+ * {@link recordNoopEdit} returns `escalate: true`. Picked deliberately small
36
+ * so the soft hint still fires once or twice before we escalate — the model
37
+ * deserves a chance to recover, but a tight bound is what actually breaks
38
+ * loops in practice.
39
+ */
40
+ export const NOOP_HARD_LIMIT = 3;
41
+
42
+ interface NoopLoopGuardOwner {
43
+ noopLoopGuard?: NoopLoopGuard;
44
+ }
45
+
46
+ /** Lazily create the per-session guard, mirroring `getFileSnapshotStore`. */
47
+ export function getNoopLoopGuard(session: NoopLoopGuardOwner): NoopLoopGuard {
48
+ if (!session.noopLoopGuard) session.noopLoopGuard = { entries: new Map() };
49
+ return session.noopLoopGuard;
50
+ }
51
+
52
+ /** Result of recording one no-op against the guard. */
53
+ export interface NoopRecordResult {
54
+ /** Consecutive identical no-op count, including the current one. */
55
+ count: number;
56
+ /** True once `count >= NOOP_HARD_LIMIT` and the caller MUST escalate. */
57
+ escalate: boolean;
58
+ }
59
+
60
+ /**
61
+ * Record a no-op edit for `canonicalPath` keyed by `inputHash` (a stable hash
62
+ * of the raw patch input bytes). Returns the running consecutive-no-op count
63
+ * and whether the caller should escalate from a soft text result to a thrown
64
+ * error.
65
+ *
66
+ * `inputHash` is intentionally derived from the raw model-authored bytes
67
+ * rather than from file content: when the model emits a different payload
68
+ * (even whitespace-only) that's progress and earns a fresh soft hint, but
69
+ * re-issuing the same bytes after being warned is what we want to break.
70
+ */
71
+ export function recordNoopEdit(
72
+ session: NoopLoopGuardOwner,
73
+ canonicalPath: string,
74
+ inputHash: string,
75
+ ): NoopRecordResult {
76
+ const guard = getNoopLoopGuard(session);
77
+ const prev = guard.entries.get(canonicalPath);
78
+ const count = prev && prev.hash === inputHash ? prev.count + 1 : 1;
79
+ guard.entries.set(canonicalPath, { hash: inputHash, count });
80
+ return { count, escalate: count >= NOOP_HARD_LIMIT };
81
+ }
82
+
83
+ /**
84
+ * Clear the no-op counter for `canonicalPath`. Call after a non-noop commit
85
+ * for the same path so a future no-op starts fresh from the soft hint.
86
+ */
87
+ export function resetNoopEdit(session: NoopLoopGuardOwner, canonicalPath: string): void {
88
+ const guard = session.noopLoopGuard;
89
+ if (!guard) return;
90
+ guard.entries.delete(canonicalPath);
91
+ }
92
+
93
+ /**
94
+ * Stable hash of the raw patch input. Bun's `Bun.hash` is xxHash64 — fast,
95
+ * non-cryptographic, more than adequate for "is this the same payload?".
96
+ */
97
+ export function hashPatchInput(input: string): string {
98
+ return Bun.hash(input).toString(16);
99
+ }
@@ -99,6 +99,7 @@ function singleResult(options: ExecutorOptions, overrides: Partial<SingleResult>
99
99
  truncated: false,
100
100
  durationMs: 1,
101
101
  tokens: 0,
102
+ requests: 0,
102
103
  ...overrides,
103
104
  };
104
105
  }
@@ -178,7 +179,7 @@ describe("runEvalAgent", () => {
178
179
  expect(runSpy).not.toHaveBeenCalled();
179
180
  });
180
181
 
181
- it("passes the parent execution context and only sets outputSchema when schema is supplied", async () => {
182
+ it("passes parent execution options and only sets outputSchema when schema is supplied", async () => {
182
183
  mockAgents();
183
184
  const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
184
185
  const abortController = new AbortController();
@@ -186,7 +187,7 @@ describe("runEvalAgent", () => {
186
187
  const session = makeSession({ depth: 2, activeModel: "p/current", modelString: "p/fallback" });
187
188
 
188
189
  await runEvalAgent(
189
- { prompt: " hello ", context: " context ", label: "My Agent", model: "p/override", schema },
190
+ { prompt: " hello ", label: "My Agent", model: "p/override", schema },
190
191
  { session, signal: abortController.signal },
191
192
  );
192
193
  await runEvalAgent({ prompt: "plain" }, { session });
@@ -199,7 +200,6 @@ describe("runEvalAgent", () => {
199
200
  expect(firstOptions.parentActiveModelPattern).toBe("p/current");
200
201
  expect(firstOptions.outputSchema).toBe(schema);
201
202
  expect(firstOptions.assignment).toBe("hello");
202
- expect(firstOptions.context).toBe("context");
203
203
  expect(firstOptions.description).toBe("My Agent");
204
204
  expect(firstOptions.modelOverride).toEqual(["p/override"]);
205
205
  expect(secondOptions.outputSchema).toBeUndefined();
@@ -542,6 +542,7 @@ describe("agent() through eval runtimes", () => {
542
542
  recentOutput: [],
543
543
  toolCount: 0,
544
544
  tokens: 0,
545
+ requests: 0,
545
546
  cost: 0,
546
547
  durationMs: 0,
547
548
  ...overrides,
@@ -674,6 +675,7 @@ describe("agent() through eval runtimes", () => {
674
675
  recentOutput: [],
675
676
  toolCount: i,
676
677
  tokens: 0,
678
+ requests: 0,
677
679
  cost: 0,
678
680
  durationMs: i * 10,
679
681
  });
@@ -34,7 +34,6 @@ const agentArgsSchema = z.object({
34
34
  prompt: z.string().min(1, "prompt must be a non-empty string"),
35
35
  agentType: z.string().min(1).optional(),
36
36
  model: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
37
- context: z.string().optional(),
38
37
  label: z.string().optional(),
39
38
  schema: z.unknown().optional(),
40
39
  });
@@ -43,7 +42,6 @@ interface EvalAgentArgs {
43
42
  prompt: string;
44
43
  agentType?: string;
45
44
  model?: string | string[];
46
- context?: string;
47
45
  label?: string;
48
46
  schema?: unknown;
49
47
  }
@@ -111,7 +109,7 @@ function assertNotPlanMode(session: ToolSession): void {
111
109
  }
112
110
 
113
111
  function renderSubagentPrompt(assignment: string): string {
114
- return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim(), independentMode: false });
112
+ return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim() });
115
113
  }
116
114
 
117
115
  function trimToUndefined(value: string | undefined): string | undefined {
@@ -135,20 +133,12 @@ function getOutputManager(session: ToolSession): AgentOutputManager {
135
133
  async function getArtifacts(session: ToolSession): Promise<{
136
134
  sessionFile: string | null;
137
135
  artifactsDir: string;
138
- contextFile?: string;
139
136
  }> {
140
137
  const sessionFile = session.getSessionFile();
141
138
  const sessionArtifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
142
139
  const artifactsDir = sessionArtifactsDir ?? path.join(os.tmpdir(), `omp-eval-agent-${Snowflake.next()}`);
143
140
  await fs.mkdir(artifactsDir, { recursive: true });
144
-
145
- const shouldWriteConversationContext = session.settings.get("irc.enabled") !== true;
146
- const compactContext = shouldWriteConversationContext ? session.getCompactContext?.() : undefined;
147
- if (!compactContext) return { sessionFile, artifactsDir };
148
-
149
- const contextFile = path.join(artifactsDir, "context.md");
150
- await Bun.write(contextFile, compactContext);
151
- return { sessionFile, artifactsDir, contextFile };
141
+ return { sessionFile, artifactsDir };
152
142
  }
153
143
 
154
144
  function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undefined, progress: AgentProgress): void {
@@ -246,11 +236,10 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
246
236
  };
247
237
  const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
248
238
  const mcpManager = options.session.mcpManager ?? MCPManager.instance();
249
- const { sessionFile, artifactsDir, contextFile } = await getArtifacts(options.session);
239
+ const { sessionFile, artifactsDir } = await getArtifacts(options.session);
250
240
  const outputManager = getOutputManager(options.session);
251
241
  const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
252
242
  const assignment = parsed.prompt.trim();
253
- const context = trimToUndefined(parsed.context);
254
243
  // Suspend eval timeout accounting while the subagent owns control. The
255
244
  // timeout clock restarts once the bridge returns to the cell runtime.
256
245
  const result = await withBridgeTimeoutPause(options.emitStatus, () =>
@@ -259,7 +248,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
259
248
  agent: effectiveAgent,
260
249
  task: renderSubagentPrompt(assignment),
261
250
  assignment,
262
- context,
263
251
  description: trimToUndefined(parsed.label),
264
252
  index: 0,
265
253
  id,
@@ -271,7 +259,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
271
259
  sessionFile,
272
260
  persistArtifacts: Boolean(sessionFile),
273
261
  artifactsDir,
274
- contextFile,
275
262
  // Eval `agent()` subagents are short-lived programmatic helpers (data
276
263
  // collection, structured output, parallel() fan-out). LSP server
277
264
  // cold-start costs tens of seconds and is pure overhead here, so it is
@@ -163,6 +163,7 @@ export async function runEvalCompletion(
163
163
  apiKey: registry.resolver(model.provider, {
164
164
  sessionId: options.session.getSessionId?.() ?? undefined,
165
165
  baseUrl: model.baseUrl,
166
+ modelId: model.id,
166
167
  }),
167
168
  signal: options.signal,
168
169
  reasoning: reasoningForTier(tier, model),
@@ -65,7 +65,7 @@ if (!globalThis.__omp_js_prelude_loaded__) {
65
65
  };
66
66
 
67
67
  const agent = async (prompt, opts, ...rest) => {
68
- const o = optionsArg("agent", opts, rest, "{ agentType, model, context, label, schema }");
68
+ const o = optionsArg("agent", opts, rest, "{ agentType, model, label, schema }");
69
69
  const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...o });
70
70
  const text = res && typeof res === "object" ? res.text : res;
71
71
  return hasOwn(o, "schema") ? JSON.parse(text) : text;
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import * as path from "node:path";
2
3
 
3
4
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
@@ -15,6 +16,7 @@ import {
15
16
  type KernelRuntimeEnv,
16
17
  PythonKernel,
17
18
  } from "./kernel";
19
+ import { resolveExplicitPythonRuntime } from "./runtime";
18
20
  import { ensurePyToolBridge, registerPyToolBridge } from "./tool-bridge";
19
21
 
20
22
  export type PythonKernelMode = "session" | "per-call";
@@ -42,6 +44,11 @@ export interface PythonExecutorOptions {
42
44
  kernelOwnerId?: string;
43
45
  /** Kernel mode (session reuse vs per-call) */
44
46
  kernelMode?: PythonKernelMode;
47
+ /**
48
+ * Explicit interpreter path (`python.interpreter` resolved from the
49
+ * session's settings). Skips automatic runtime discovery when set.
50
+ */
51
+ interpreter?: string;
45
52
  /** Restart the kernel before executing */
46
53
  reset?: boolean;
47
54
  /** Session file path for accessing task outputs */
@@ -116,9 +123,9 @@ export interface PythonResult {
116
123
  // ---------------------------------------------------------------------------
117
124
  // Session bookkeeping
118
125
  //
119
- // One PythonKernel subprocess per (session id, cwd) tuple. The runner mutates
120
- // process-global cwd/sys.path during execution, so cross-directory work MUST
121
- // never share a live kernel. Multiple agent owners can still register against
126
+ // One PythonKernel subprocess per (session id, cwd, interpreter) tuple. The
127
+ // runner mutates process-global cwd/sys.path during execution, so cross-directory
128
+ // work must never share a live kernel. Multiple agent owners can still register against
122
129
  // the same tuple; the kernel stays alive until the last owner detaches.
123
130
  // ---------------------------------------------------------------------------
124
131
 
@@ -139,8 +146,19 @@ function normalizeSessionCwd(cwd: string): string {
139
146
  return path.resolve(cwd);
140
147
  }
141
148
 
142
- function buildSessionKey(sessionId: string, cwd: string): string {
143
- return `${sessionId}\0${normalizeSessionCwd(cwd)}`;
149
+ function normalizeExplicitInterpreter(cwd: string, interpreter: string | undefined): string {
150
+ if (interpreter === undefined) return "";
151
+ const resolved = resolveExplicitPythonRuntime(interpreter, cwd, {}).pythonPath;
152
+ try {
153
+ return fs.realpathSync.native(resolved);
154
+ } catch {
155
+ return resolved;
156
+ }
157
+ }
158
+
159
+ function buildSessionKey(sessionId: string, cwd: string, interpreter: string | undefined): string {
160
+ const normalizedCwd = normalizeSessionCwd(cwd);
161
+ return `${sessionId}\0${normalizedCwd}\0${normalizeExplicitInterpreter(normalizedCwd, interpreter)}`;
144
162
  }
145
163
 
146
164
  // ---------------------------------------------------------------------------
@@ -326,6 +344,7 @@ async function startKernel(cwd: string, options: PythonExecutorOptions): Promise
326
344
  env: buildKernelEnv(options),
327
345
  signal: options.signal,
328
346
  deadlineMs: options.deadlineMs,
347
+ interpreter: options.interpreter,
329
348
  });
330
349
  }
331
350
 
@@ -587,7 +606,10 @@ async function executeWithKernel(
587
606
  }
588
607
 
589
608
  async function ensureKernelAvailable(cwd: string, options: PythonExecutorOptions): Promise<void> {
590
- const availability = await waitForPromiseWithCancellation(checkPythonKernelAvailability(cwd), options);
609
+ const availability = await waitForPromiseWithCancellation(
610
+ checkPythonKernelAvailability(cwd, options.interpreter),
611
+ options,
612
+ );
591
613
  if (!availability.ok) {
592
614
  throw new Error(availability.reason ?? "Python kernel unavailable");
593
615
  }
@@ -618,7 +640,7 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
618
640
 
619
641
  async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
620
642
  const sessionId = options.sessionId ?? `session:${cwd}`;
621
- const sessionKey = buildSessionKey(sessionId, cwd);
643
+ const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter);
622
644
  if (options.bridge && !options.bridgeSessionId) {
623
645
  options.bridgeSessionId = sessionId;
624
646
  }
@@ -19,13 +19,17 @@ function readSetting<T>(session: ToolSession, key: string): T | undefined {
19
19
  return settings?.get?.(key);
20
20
  }
21
21
 
22
+ function readInterpreterSetting(session: ToolSession): string | undefined {
23
+ return readSetting<string>(session, "python.interpreter")?.trim() || undefined;
24
+ }
25
+
22
26
  export default {
23
27
  id: "python",
24
28
  label: "Python",
25
29
  highlightLang: "python",
26
30
 
27
31
  async isAvailable(session: ToolSession): Promise<boolean> {
28
- const availability = await checkPythonKernelAvailability(session.cwd);
32
+ const availability = await checkPythonKernelAvailability(session.cwd, readInterpreterSetting(session));
29
33
  return availability.ok;
30
34
  },
31
35
 
@@ -37,6 +41,7 @@ export default {
37
41
  signal: opts.signal,
38
42
  sessionId: namespaceSessionId(opts.sessionId),
39
43
  kernelMode,
44
+ interpreter: readInterpreterSetting(opts.session),
40
45
  sessionFile: opts.sessionFile,
41
46
  artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
42
47
  localRoots: resolveEvalUrlRoots(opts.session),