@oh-my-pi/pi-coding-agent 15.10.11 → 15.10.12

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 (121) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.js +5349 -5328
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli-commands.d.ts +12 -0
  5. package/dist/types/commands/launch.d.ts +4 -0
  6. package/dist/types/config/api-key-resolver.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +18 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  12. package/dist/types/eval/py/executor.d.ts +5 -0
  13. package/dist/types/eval/py/kernel.d.ts +6 -1
  14. package/dist/types/eval/py/runtime.d.ts +9 -0
  15. package/dist/types/exec/bash-executor.d.ts +2 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/memory-backend/index.d.ts +1 -0
  19. package/dist/types/memory-backend/runtime.d.ts +4 -0
  20. package/dist/types/memory-backend/types.d.ts +66 -1
  21. package/dist/types/modes/index.d.ts +3 -3
  22. package/dist/types/modes/interactive-mode.d.ts +7 -2
  23. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  24. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  26. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  27. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  28. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  29. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  30. package/dist/types/modes/types.d.ts +2 -0
  31. package/dist/types/secrets/index.d.ts +1 -1
  32. package/dist/types/secrets/obfuscator.d.ts +8 -2
  33. package/dist/types/session/agent-session.d.ts +14 -2
  34. package/dist/types/session/streaming-output.d.ts +23 -0
  35. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  36. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  37. package/dist/types/slash-commands/types.d.ts +1 -1
  38. package/dist/types/system-prompt.d.ts +2 -0
  39. package/dist/types/task/executor.d.ts +1 -0
  40. package/dist/types/task/index.d.ts +2 -2
  41. package/dist/types/task/types.d.ts +8 -0
  42. package/dist/types/thinking.d.ts +4 -0
  43. package/dist/types/tiny/title-client.d.ts +11 -0
  44. package/dist/types/tiny/title-protocol.d.ts +1 -0
  45. package/dist/types/tools/index.d.ts +6 -0
  46. package/dist/types/utils/git.d.ts +15 -2
  47. package/dist/types/utils/title-generator.d.ts +3 -2
  48. package/package.json +10 -10
  49. package/src/auto-thinking/classifier.ts +1 -0
  50. package/src/cli/args.ts +3 -0
  51. package/src/cli-commands.ts +29 -0
  52. package/src/cli.ts +8 -9
  53. package/src/commands/launch.ts +4 -0
  54. package/src/commit/model-selection.ts +3 -2
  55. package/src/config/api-key-resolver.ts +8 -6
  56. package/src/config/model-registry.ts +97 -30
  57. package/src/config/model-resolver.ts +60 -0
  58. package/src/config/settings-schema.ts +43 -15
  59. package/src/config/settings.ts +61 -3
  60. package/src/edit/hashline/execute.ts +39 -2
  61. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  62. package/src/eval/completion-bridge.ts +1 -0
  63. package/src/eval/py/executor.ts +29 -7
  64. package/src/eval/py/index.ts +6 -1
  65. package/src/eval/py/kernel.ts +31 -11
  66. package/src/eval/py/runtime.ts +37 -0
  67. package/src/exec/bash-executor.ts +82 -3
  68. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  69. package/src/extensibility/extensions/runner.ts +6 -1
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/hindsight/bank.ts +17 -2
  72. package/src/internal-urls/docs-index.generated.ts +3 -3
  73. package/src/main.ts +18 -6
  74. package/src/memories/index.ts +2 -0
  75. package/src/memory-backend/index.ts +1 -0
  76. package/src/memory-backend/local-backend.ts +9 -0
  77. package/src/memory-backend/off-backend.ts +9 -0
  78. package/src/memory-backend/runtime.ts +66 -0
  79. package/src/memory-backend/types.ts +81 -1
  80. package/src/mnemopi/backend.ts +151 -4
  81. package/src/modes/acp/acp-agent.ts +119 -11
  82. package/src/modes/components/assistant-message.ts +19 -21
  83. package/src/modes/components/footer.ts +3 -1
  84. package/src/modes/components/status-line/component.ts +118 -34
  85. package/src/modes/controllers/command-controller.ts +1 -1
  86. package/src/modes/controllers/input-controller.ts +1 -0
  87. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  88. package/src/modes/index.ts +3 -21
  89. package/src/modes/interactive-mode.ts +39 -9
  90. package/src/modes/oauth-manual-input.ts +30 -3
  91. package/src/modes/rpc/rpc-client.ts +154 -3
  92. package/src/modes/rpc/rpc-mode.ts +97 -12
  93. package/src/modes/rpc/rpc-subagents.ts +265 -0
  94. package/src/modes/rpc/rpc-types.ts +81 -1
  95. package/src/modes/setup-wizard/index.ts +12 -2
  96. package/src/modes/setup-wizard/lazy.ts +16 -0
  97. package/src/modes/types.ts +2 -0
  98. package/src/sdk.ts +8 -1
  99. package/src/secrets/index.ts +8 -1
  100. package/src/secrets/obfuscator.ts +39 -18
  101. package/src/session/agent-session.ts +179 -54
  102. package/src/session/streaming-output.ts +166 -10
  103. package/src/slash-commands/acp-builtins.ts +24 -0
  104. package/src/slash-commands/builtin-registry.ts +20 -0
  105. package/src/slash-commands/types.ts +1 -1
  106. package/src/system-prompt.ts +14 -0
  107. package/src/task/executor.ts +13 -12
  108. package/src/task/index.ts +9 -8
  109. package/src/task/render.ts +18 -3
  110. package/src/task/types.ts +9 -0
  111. package/src/thinking.ts +7 -0
  112. package/src/tiny/title-client.ts +34 -5
  113. package/src/tiny/title-protocol.ts +1 -1
  114. package/src/tiny/worker.ts +6 -4
  115. package/src/tools/bash.ts +46 -5
  116. package/src/tools/image-gen.ts +11 -4
  117. package/src/tools/index.ts +13 -1
  118. package/src/tools/inspect-image.ts +1 -0
  119. package/src/utils/commit-message-generator.ts +1 -0
  120. package/src/utils/git.ts +267 -13
  121. package/src/utils/title-generator.ts +24 -5
@@ -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
 
@@ -898,6 +955,7 @@ export class Settings {
898
955
 
899
956
  #rebuildMerged(): void {
900
957
  this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project);
958
+ this.#merged = this.#deepMerge(this.#merged, this.#configOverlay);
901
959
  this.#merged = this.#deepMerge(this.#merged, this.#overrides);
902
960
  this.#resolvedCache.clear();
903
961
  }
@@ -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
+ }
@@ -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),
@@ -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),
@@ -17,7 +17,13 @@ import { Settings } from "../../config/settings";
17
17
  import { type KernelDisplayOutput, renderKernelDisplay } from "./display";
18
18
  import { PYTHON_PRELUDE } from "./prelude";
19
19
  import RUNNER_SCRIPT from "./runner.py" with { type: "text" };
20
- import { enumeratePythonRuntimes, filterEnv, type PythonRuntime, resolvePythonRuntime } from "./runtime";
20
+ import {
21
+ enumeratePythonRuntimes,
22
+ filterEnv,
23
+ type PythonRuntime,
24
+ resolveExplicitPythonRuntime,
25
+ resolvePythonRuntime,
26
+ } from "./runtime";
21
27
  import { hostHasInheritableConsole, shouldHideKernelWindow } from "./spawn-options";
22
28
 
23
29
  export type { KernelDisplayOutput, PythonStatusEvent } from "./display";
@@ -96,6 +102,11 @@ interface KernelLifecycleOptions {
96
102
  interface KernelStartOptions extends KernelLifecycleOptions {
97
103
  cwd: string;
98
104
  env?: Record<string, string | undefined>;
105
+ /**
106
+ * Explicit interpreter path (`python.interpreter` from the session's
107
+ * settings). When set, runtime discovery is skipped entirely.
108
+ */
109
+ interpreter?: string;
99
110
  }
100
111
 
101
112
  interface KernelShutdownOptions {
@@ -129,20 +140,24 @@ function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string)
129
140
  throw createAbortError("AbortError", typeof reason === "string" ? reason : fallbackReason);
130
141
  }
131
142
 
132
- // Cache successful probes per resolved cwd: every cell otherwise pays one (or
133
- // two — backend.isAvailable + ensureKernelAvailable) interpreter spawns even
134
- // when the kernel is already hot. Failures are not cached so installing a
135
- // Python mid-session is picked up on the next attempt.
143
+ // Cache successful probes per resolved cwd + explicit interpreter: every cell
144
+ // otherwise pays one (or two — backend.isAvailable + ensureKernelAvailable)
145
+ // interpreter spawns even when the kernel is already hot. Failures are not
146
+ // cached so installing a Python mid-session is picked up on the next attempt.
136
147
  const availabilityCache = new Map<string, Promise<PythonKernelAvailability>>();
137
148
 
138
- export async function checkPythonKernelAvailability(cwd: string): Promise<PythonKernelAvailability> {
149
+ export async function checkPythonKernelAvailability(
150
+ cwd: string,
151
+ interpreter?: string,
152
+ ): Promise<PythonKernelAvailability> {
139
153
  if (isBunTestRuntime() || $flag("PI_PYTHON_SKIP_CHECK")) {
140
154
  return { ok: true };
141
155
  }
142
- const key = path.resolve(cwd);
156
+ const resolvedCwd = path.resolve(cwd);
157
+ const key = `${resolvedCwd}\0${interpreter ?? ""}`;
143
158
  const cached = availabilityCache.get(key);
144
159
  if (cached) return await cached;
145
- const probe = probePythonKernelAvailability(key);
160
+ const probe = probePythonKernelAvailability(resolvedCwd, interpreter);
146
161
  availabilityCache.set(key, probe);
147
162
  const result = await probe;
148
163
  if (!result.ok && availabilityCache.get(key) === probe) {
@@ -151,12 +166,14 @@ export async function checkPythonKernelAvailability(cwd: string): Promise<Python
151
166
  return result;
152
167
  }
153
168
 
154
- async function probePythonKernelAvailability(cwd: string): Promise<PythonKernelAvailability> {
169
+ async function probePythonKernelAvailability(cwd: string, interpreter?: string): Promise<PythonKernelAvailability> {
155
170
  try {
156
171
  const settings = await Settings.init();
157
172
  const { env } = settings.getShellConfig();
158
173
  const baseEnv = filterEnv(env);
159
- const runtimes = enumeratePythonRuntimes(cwd, baseEnv);
174
+ const runtimes = interpreter
175
+ ? [resolveExplicitPythonRuntime(interpreter, cwd, baseEnv)]
176
+ : enumeratePythonRuntimes(cwd, baseEnv);
160
177
  if (runtimes.length === 0) {
161
178
  return { ok: false, reason: "Python executable not found on PATH" };
162
179
  }
@@ -239,6 +256,7 @@ export class PythonKernel {
239
256
  "PythonKernel.start:availabilityCheck",
240
257
  checkPythonKernelAvailability,
241
258
  options.cwd,
259
+ options.interpreter,
242
260
  );
243
261
  if (!availability.ok) {
244
262
  throw new Error(availability.reason ?? "Python kernel unavailable");
@@ -251,7 +269,9 @@ export class PythonKernel {
251
269
  let runtime = availability.runtime;
252
270
  if (!runtime) {
253
271
  const { env: shellEnv } = (await Settings.init()).getShellConfig();
254
- runtime = resolvePythonRuntime(options.cwd, filterEnv(shellEnv));
272
+ runtime = options.interpreter
273
+ ? resolveExplicitPythonRuntime(options.interpreter, options.cwd, filterEnv(shellEnv))
274
+ : resolvePythonRuntime(options.cwd, filterEnv(shellEnv));
255
275
  }
256
276
  const spawnEnv: Record<string, string> = {};
257
277
  for (const [key, value] of Object.entries(runtime.env)) {
@@ -5,6 +5,7 @@
5
5
  * for both the shared gateway and local kernel spawning.
6
6
  */
7
7
  import * as fs from "node:fs";
8
+ import * as os from "node:os";
8
9
  import * as path from "node:path";
9
10
  import { $env, $which, getPythonEnvDir } from "@oh-my-pi/pi-utils";
10
11
 
@@ -182,6 +183,42 @@ function venvBinDir(venvPath: string): string {
182
183
  return process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
183
184
  }
184
185
 
186
+ function detectExplicitVenv(pythonPath: string): { venvPath: string; binDir: string } | undefined {
187
+ const binDir = path.dirname(pythonPath);
188
+ const venvPath = path.dirname(binDir);
189
+ if (fs.existsSync(path.join(venvPath, "pyvenv.cfg"))) {
190
+ return { venvPath, binDir };
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ /**
196
+ * Resolve an explicitly configured interpreter (`python.interpreter`) into a
197
+ * runtime, bypassing discovery. Does not probe or validate the executable —
198
+ * callers must check it actually runs. `~` expands to the home directory and
199
+ * relative paths resolve against `cwd`. When the interpreter sits inside a
200
+ * virtualenv (a `pyvenv.cfg` above its bin dir), the venv activation env is
201
+ * applied so subprocesses and `pip` resolve consistently.
202
+ */
203
+ export function resolveExplicitPythonRuntime(
204
+ interpreter: string,
205
+ cwd: string,
206
+ baseEnv: Record<string, string | undefined>,
207
+ ): PythonRuntime {
208
+ const expanded =
209
+ interpreter === "~"
210
+ ? os.homedir()
211
+ : interpreter.startsWith("~/")
212
+ ? path.join(os.homedir(), interpreter.slice(2))
213
+ : interpreter;
214
+ const pythonPath = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
215
+ const venv = detectExplicitVenv(pythonPath);
216
+ if (venv) {
217
+ return { pythonPath, env: applyVenvEnv(baseEnv, venv.venvPath, venv.binDir), venvPath: venv.venvPath };
218
+ }
219
+ return { pythonPath, env: { ...baseEnv } };
220
+ }
221
+
185
222
  /**
186
223
  * Enumerate candidate Python runtimes in priority order: an active/project venv,
187
224
  * the managed `~/.omp/python-env`, then the system interpreter on PATH. Every