@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.2

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 (114) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-xfpkakrn.md} +47 -0
  3. package/dist/cli.js +3891 -3885
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  18. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  19. package/dist/types/modes/interactive-mode.d.ts +1 -0
  20. package/dist/types/modes/theme/theme.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +2 -0
  22. package/dist/types/secrets/index.d.ts +22 -0
  23. package/dist/types/secrets/obfuscator.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +4 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  27. package/dist/types/session/session-storage.d.ts +5 -2
  28. package/dist/types/session/session-tools.d.ts +2 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -1
  30. package/dist/types/system-prompt.d.ts +8 -0
  31. package/dist/types/task/executor.d.ts +27 -0
  32. package/dist/types/task/persisted-revive.d.ts +7 -0
  33. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  34. package/dist/types/tools/browser/attach.d.ts +9 -1
  35. package/dist/types/tools/browser/launch.d.ts +20 -1
  36. package/dist/types/tools/browser/registry.d.ts +2 -0
  37. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  38. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  40. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  41. package/dist/types/utils/image-loading.d.ts +2 -0
  42. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  43. package/dist/types/web/search/types.d.ts +2 -2
  44. package/package.json +12 -12
  45. package/src/commit/agentic/index.ts +2 -1
  46. package/src/commit/pipeline.ts +3 -2
  47. package/src/config/keybindings.ts +6 -1
  48. package/src/config/settings-schema.ts +16 -3
  49. package/src/edit/hashline/block-resolver.ts +5 -5
  50. package/src/edit/hashline/diff.ts +22 -15
  51. package/src/edit/hashline/execute.ts +14 -13
  52. package/src/edit/renderer.ts +3 -3
  53. package/src/eval/executor-base.ts +39 -0
  54. package/src/eval/jl/executor.ts +13 -2
  55. package/src/eval/js/context-manager.ts +97 -14
  56. package/src/eval/js/executor.ts +3 -0
  57. package/src/eval/js/index.ts +1 -0
  58. package/src/eval/py/executor.ts +55 -15
  59. package/src/eval/rb/executor.ts +8 -1
  60. package/src/extensibility/extensions/runner.ts +121 -4
  61. package/src/extensibility/extensions/types.ts +15 -0
  62. package/src/extensibility/extensions/wrapper.ts +19 -2
  63. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  64. package/src/live/visualizer.ts +8 -1
  65. package/src/lsp/index.ts +51 -11
  66. package/src/main.ts +3 -0
  67. package/src/modes/acp/acp-agent.ts +16 -5
  68. package/src/modes/components/assistant-message.ts +5 -10
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/keybinding-hints.ts +4 -13
  71. package/src/modes/components/status-line/segments.ts +4 -5
  72. package/src/modes/components/tool-execution.ts +4 -5
  73. package/src/modes/controllers/input-controller.ts +18 -40
  74. package/src/modes/controllers/live-command-controller.ts +1 -0
  75. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  76. package/src/modes/interactive-mode.ts +59 -2
  77. package/src/modes/theme/theme.ts +13 -6
  78. package/src/modes/types.ts +2 -0
  79. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  80. package/src/prompts/system/plan-mode-active.md +5 -5
  81. package/src/sdk.ts +47 -16
  82. package/src/secrets/index.ts +83 -3
  83. package/src/secrets/obfuscator.ts +70 -15
  84. package/src/session/agent-session-types.ts +7 -1
  85. package/src/session/agent-session.ts +393 -112
  86. package/src/session/codex-auto-reset.ts +594 -123
  87. package/src/session/eval-runner.ts +2 -0
  88. package/src/session/messages.ts +1 -1
  89. package/src/session/session-maintenance.ts +48 -1
  90. package/src/session/session-manager.ts +4 -3
  91. package/src/session/session-stats.ts +14 -3
  92. package/src/session/session-storage.ts +47 -15
  93. package/src/session/session-tools.ts +30 -2
  94. package/src/session/turn-recovery.ts +15 -2
  95. package/src/slash-commands/builtin-registry.ts +21 -15
  96. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  97. package/src/system-prompt.ts +13 -1
  98. package/src/task/executor.ts +168 -15
  99. package/src/task/persisted-revive.ts +26 -1
  100. package/src/telemetry-export.ts +8 -4
  101. package/src/tools/bash-interceptor.ts +85 -8
  102. package/src/tools/bash.ts +1 -1
  103. package/src/tools/browser/attach.ts +35 -10
  104. package/src/tools/browser/launch.ts +60 -11
  105. package/src/tools/browser/registry.ts +18 -2
  106. package/src/tools/browser/tab-protocol.ts +8 -0
  107. package/src/tools/browser/tab-supervisor.ts +31 -4
  108. package/src/tools/browser/tab-worker.ts +32 -2
  109. package/src/tools/output-meta.ts +16 -1
  110. package/src/tools/shell-tokenize.ts +101 -0
  111. package/src/utils/image-loading.ts +8 -3
  112. package/src/utils/shell-snapshot.ts +31 -21
  113. package/src/web/search/providers/duckduckgo.ts +183 -15
  114. package/src/web/search/types.ts +6 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.2.1",
4
+ "version": "17.2.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.2.1",
56
- "@oh-my-pi/omp-stats": "17.2.1",
57
- "@oh-my-pi/pi-agent-core": "17.2.1",
58
- "@oh-my-pi/pi-ai": "17.2.1",
59
- "@oh-my-pi/pi-catalog": "17.2.1",
60
- "@oh-my-pi/pi-mnemopi": "17.2.1",
61
- "@oh-my-pi/pi-natives": "17.2.1",
62
- "@oh-my-pi/pi-tui": "17.2.1",
63
- "@oh-my-pi/pi-utils": "17.2.1",
64
- "@oh-my-pi/pi-wire": "17.2.1",
65
- "@oh-my-pi/snapcompact": "17.2.1",
55
+ "@oh-my-pi/hashline": "17.2.2",
56
+ "@oh-my-pi/omp-stats": "17.2.2",
57
+ "@oh-my-pi/pi-agent-core": "17.2.2",
58
+ "@oh-my-pi/pi-ai": "17.2.2",
59
+ "@oh-my-pi/pi-catalog": "17.2.2",
60
+ "@oh-my-pi/pi-mnemopi": "17.2.2",
61
+ "@oh-my-pi/pi-natives": "17.2.2",
62
+ "@oh-my-pi/pi-tui": "17.2.2",
63
+ "@oh-my-pi/pi-utils": "17.2.2",
64
+ "@oh-my-pi/pi-wire": "17.2.2",
65
+ "@oh-my-pi/snapcompact": "17.2.2",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/api-logs": "^0.220.0",
68
68
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -9,7 +9,7 @@ import { resolvePrimaryModel, resolveSmolModel } from "../../commit/model-select
9
9
  import type { CommitCommandArgs, ConventionalAnalysis, NumstatEntry } from "../../commit/types";
10
10
  import { ModelRegistry } from "../../config/model-registry";
11
11
  import { Settings } from "../../config/settings";
12
- import { discoverAuthStorage, discoverContextFiles } from "../../sdk";
12
+ import { discoverAuthStorage, discoverContextFiles, loadCliExtensionProviders } from "../../sdk";
13
13
  import * as git from "../../utils/git";
14
14
  import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent";
15
15
  import { generateFallbackProposal } from "./fallback";
@@ -32,6 +32,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise<void> {
32
32
  process.stdout.write("● Resolving model...\n");
33
33
  const modelRegistry = new ModelRegistry(authStorage);
34
34
  await modelRegistry.refresh();
35
+ await loadCliExtensionProviders(modelRegistry, settings, cwd);
35
36
  const stagedFilesPromise = (async () => {
36
37
  let stagedFiles = await git.diff.changedFiles(cwd, { cached: true });
37
38
  if (stagedFiles.length === 0) {
@@ -4,7 +4,7 @@ import type { Api, ApiKey, Model } from "@oh-my-pi/pi-ai";
4
4
  import { getProjectDir, logger, prompt } from "@oh-my-pi/pi-utils";
5
5
  import { ModelRegistry } from "../config/model-registry";
6
6
  import { Settings } from "../config/settings";
7
- import { discoverAuthStorage } from "../sdk";
7
+ import { discoverAuthStorage, loadCliExtensionProviders } from "../sdk";
8
8
  import { loadProjectContextFiles } from "../system-prompt";
9
9
  import * as git from "../utils/git";
10
10
  import { runAgenticCommit } from "./agentic";
@@ -40,11 +40,12 @@ export async function runCommitCommand(args: CommitCommandArgs): Promise<void> {
40
40
 
41
41
  async function runLegacyCommitCommand(args: CommitCommandArgs): Promise<void> {
42
42
  const cwd = getProjectDir();
43
- const settings = await Settings.init();
43
+ const settings = await Settings.init({ cwd });
44
44
  const commitSettings = settings.getGroup("commit");
45
45
  const authStorage = await discoverAuthStorage();
46
46
  const modelRegistry = new ModelRegistry(authStorage);
47
47
  await modelRegistry.refresh();
48
+ await loadCliExtensionProviders(modelRegistry, settings, cwd);
48
49
 
49
50
  const {
50
51
  model: primaryModel,
@@ -53,6 +53,7 @@ interface AppKeybindings {
53
53
  "app.plan.toggle": true;
54
54
  "app.history.search": true;
55
55
  "app.stt.toggle": true;
56
+ "app.live.toggle": true;
56
57
  }
57
58
 
58
59
  export type AppKeybinding = keyof AppKeybindings;
@@ -92,7 +93,7 @@ export const KEYBINDINGS = {
92
93
  description: "Suspend application",
93
94
  },
94
95
  "app.display.reset": {
95
- defaultKeys: "ctrl+l",
96
+ defaultKeys: "alt+l",
96
97
  description: "Reset terminal display",
97
98
  },
98
99
  "app.thinking.cycle": {
@@ -222,6 +223,10 @@ export const KEYBINDINGS = {
222
223
  defaultKeys: [],
223
224
  description: "Toggle speech-to-text (default gesture: hold Space)",
224
225
  },
226
+ "app.live.toggle": {
227
+ defaultKeys: "ctrl+l",
228
+ description: "Start or stop live voice mode (/live)",
229
+ },
225
230
  } as const satisfies KeybindingDefinitions;
226
231
 
227
232
  /**
@@ -5289,7 +5289,7 @@ export const SETTINGS_SCHEMA = {
5289
5289
  group: "Services",
5290
5290
  label: "Codex Auto-Redeem Saved Resets",
5291
5291
  description:
5292
- "When a turn is blocked by the Codex weekly limit on the active account and no other account is available, run the conservative saved-reset check. unset asks before spending the first eligible reset, yes spends eligible resets without prompting, and no disables the check entirely. Requires retries enabled.",
5292
+ "Spend saved Codex rate-limit resets automatically: restore an account blocked by an exhausted 5h or weekly window when a turn is stuck and no other account can take over, and salvage credits that are about to expire. unset asks before the first spend, yes spends without prompting, and no disables both checks.",
5293
5293
  options: [
5294
5294
  {
5295
5295
  value: "unset",
@@ -5309,7 +5309,7 @@ export const SETTINGS_SCHEMA = {
5309
5309
  group: "Services",
5310
5310
  label: "Codex Auto-Redeem Min Block",
5311
5311
  description:
5312
- "Only auto-redeem when the natural weekly reset is at least this many minutes away (don't spend a ~30-day credit to save a short wait).",
5312
+ "Only auto-redeem when the natural unblock — the latest reset among the exhausted 5h/weekly windows — is at least this many minutes away (don't spend a scarce credit to save a short wait). Raise it (e.g. 360) to ignore 5h-only blocks.",
5313
5313
  },
5314
5314
  },
5315
5315
  "codexResets.keepCredits": {
@@ -5319,7 +5319,19 @@ export const SETTINGS_SCHEMA = {
5319
5319
  tab: "providers",
5320
5320
  group: "Services",
5321
5321
  label: "Codex Auto-Redeem Reserve",
5322
- description: "Never auto-spend below this many saved resets (0 = the last credit may be spent automatically).",
5322
+ description:
5323
+ "Never auto-spend below this many saved resets (0 = the last credit may be spent automatically). Credits about to expire are exempt — a reserved credit that expires preserves nothing.",
5324
+ },
5325
+ },
5326
+ "codexResets.salvageHorizonHours": {
5327
+ type: "number",
5328
+ default: 12,
5329
+ ui: {
5330
+ tab: "providers",
5331
+ group: "Services",
5332
+ label: "Codex Reset Salvage Horizon",
5333
+ description:
5334
+ "Spend a saved Codex reset automatically when it would otherwise expire within this many hours and either chat window (5h or weekly) has meaningful usage to restore (0 disables expiry salvage).",
5323
5335
  },
5324
5336
  },
5325
5337
  "provider.appendOnlyContext": {
@@ -5784,6 +5796,7 @@ export interface CodexResetsSettings {
5784
5796
  autoRedeem: CodexAutoRedeemMode;
5785
5797
  minBlockedMinutes: number;
5786
5798
  keepCredits: number;
5799
+ salvageHorizonHours: number;
5787
5800
  }
5788
5801
 
5789
5802
  export interface GcSettings {
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Tree-sitter-backed {@link BlockResolver} for the hashline block replace
3
- * operator. Bridges the pure hashline seam to the native `blockRangeAt`
4
- * primitive in `@oh-my-pi/pi-natives`, which infers the language from the file
5
- * path and returns the 1-indexed line span of the syntactic block beginning on
6
- * the requested line (or `null` when none can be resolved).
2
+ * Tree-sitter-backed {@link BlockResolver} for the hashline `N*` locator.
3
+ * Bridges the pure hashline seam to the native `blockRangeAt` primitive in
4
+ * `@oh-my-pi/pi-natives`, which infers the language from the file path and
5
+ * returns the 1-indexed line span of the syntactic block beginning on the
6
+ * requested line (or `null` when none can be resolved).
7
7
  */
8
8
  import type { BlockResolver } from "@oh-my-pi/hashline";
9
9
  import { blockRangeAt } from "@oh-my-pi/pi-natives";
@@ -17,6 +17,7 @@ import {
17
17
  type Cursor,
18
18
  computeFileHash,
19
19
  type Edit,
20
+ forkClipboard,
20
21
  Patch as HashlinePatch,
21
22
  hasBlockEdit,
22
23
  MismatchError,
@@ -53,10 +54,10 @@ export interface HashlineDiffOptions {
53
54
  skipHashValidation?: boolean;
54
55
  /**
55
56
  * Clipboard register shared across the sections of one patch preview.
56
- * `CUT` in an earlier section feeds `PASTE` in a later one, so the preview
57
- * matches apply. Multi-section previews MUST thread one register through
58
- * sections in patch order; omitted, each section gets a
59
- * private register (same-file cut/paste still previews correctly).
57
+ * `CUT` in an earlier section feeds a register-backed `PUT` in a later one,
58
+ * so the preview matches apply. Multi-section previews MUST thread one
59
+ * register through sections in patch order; omitted, each section gets a
60
+ * private register (same-file cut/put still previews correctly).
60
61
  */
61
62
  clipboard?: Clipboard;
62
63
  }
@@ -153,9 +154,11 @@ async function readSectionForPreview(
153
154
 
154
155
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
155
156
  return edits.some(edit => {
156
- if (edit.kind === "delete") return true;
157
- if (edit.kind === "block") return true;
158
- if (edit.kind === "cut") return true;
157
+ if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true;
158
+ if (edit.kind === "paste") {
159
+ if (edit.at.kind === "span") return true;
160
+ return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor";
161
+ }
159
162
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
160
163
  });
161
164
  }
@@ -279,19 +282,23 @@ function buildStreamingSectionDiff(
279
282
  onUnresolved: "drop",
280
283
  });
281
284
  const fileLines = normalized.split("\n");
282
- // Expand clipboard ops so a `PASTE` previews the moved rows. Resolve into
283
- // a scratch register and commit it back only on success: a mid-typed
284
- // capture (out-of-range while the range digits are still streaming,
285
- // transient sequencing violations) throws AFTER earlier captures may have
286
- // landed, and leaking those into the shared register would let a later
287
- // section preview a paste that never happened. On failure, fall back to
288
- // filtering the clipboard edits out of this section's frame.
289
- const scratch: Clipboard = { ...clipboard };
285
+ // Expand register-backed PUT ops so moved rows appear in the preview.
286
+ // Resolve into a transactional register and publish it only on success: a
287
+ // mid-typed capture (out-of-range while the range digits are still
288
+ // streaming, transient sequencing violations) throws AFTER earlier
289
+ // captures may have landed, and leaking those into the shared register
290
+ // would let a later section preview a PUT that never happened. On failure,
291
+ // filter the clipboard edits out of this section's frame.
292
+ const scratch = forkClipboard(clipboard);
290
293
  let resolved: readonly Edit[];
291
294
  try {
292
295
  resolved = resolveClipboardEdits(blockResolved, fileLines, scratch, { onEmptyPaste: "drop" });
293
296
  if (scratch.lines === undefined) delete clipboard.lines;
294
297
  else clipboard.lines = scratch.lines;
298
+ if (scratch.named === undefined) delete clipboard.named;
299
+ else clipboard.named = scratch.named;
300
+ if (scratch.pendingAnonCuts === undefined) delete clipboard.pendingAnonCuts;
301
+ else clipboard.pendingAnonCuts = scratch.pendingAnonCuts;
295
302
  } catch {
296
303
  resolved = blockResolved.filter(edit => edit.kind !== "cut" && edit.kind !== "paste");
297
304
  }
@@ -21,6 +21,7 @@ import {
21
21
  Patcher,
22
22
  type PatchSectionResult,
23
23
  type PreparedSection,
24
+ startClipboardBatch,
24
25
  } from "@oh-my-pi/hashline";
25
26
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
26
27
  import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
@@ -48,7 +49,7 @@ export interface ExecuteHashlineSingleOptions {
48
49
 
49
50
  function noChangeDiagnostic(path: string): string {
50
51
  // The patch parsed and applied cleanly but produced no change — the
51
- // `|literal` body rows matched the file content at the targeted lines
52
+ // `+TEXT` body rows matched the file content at the targeted lines
52
53
  // byte-for-byte. The model usually misreads this as "wrong anchor, try
53
54
  // again with a bigger payload" and starts duplicating content; the
54
55
  // message below names the cause directly so the next turn can re-read
@@ -103,14 +104,14 @@ interface RenderedSection {
103
104
  }
104
105
 
105
106
  const BLOCK_OP_LABELS: Record<BlockResolution["op"], string> = {
106
- replace: "SWAP.BLK",
107
- insert_after: "INS.BLK.POST",
108
- cut: "CUT.BLK",
109
- paste_after: "PASTE.BLK.POST",
107
+ replace: "PUT N*:",
108
+ insert_after: "PUT >N*:",
109
+ cut: "CUT N*",
110
+ paste_after: "PUT >N*",
110
111
  };
111
112
 
112
113
  function formatBlockResolution(resolution: BlockResolution): string {
113
- const op = BLOCK_OP_LABELS[resolution.op];
114
+ const op = BLOCK_OP_LABELS[resolution.op].replace("N", String(resolution.anchorLine));
114
115
  const lines = resolution.end - resolution.start + 1;
115
116
  const span =
116
117
  resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
@@ -120,7 +121,7 @@ function formatBlockResolution(resolution: BlockResolution): string {
120
121
  : resolution.op === "paste_after"
121
122
  ? `; clipboard lands after line ${resolution.end}`
122
123
  : "";
123
- return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})${suffix}`;
124
+ return `${op} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})${suffix}`;
124
125
  }
125
126
 
126
127
  function renderSection(
@@ -229,11 +230,11 @@ export async function executeHashlineSingle(
229
230
  const enforceSeenLines = options.session.settings.get("edit.enforceSeenLines");
230
231
  const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver, enforceSeenLines });
231
232
 
232
- // The clipboard register is session-persistent: `CUT` in one edit call can
233
- // `PASTE` in a later one. Each batch works on a fork and publishes it back
234
- // only after writes land.
233
+ // Named registers persist across edit calls; the anonymous register is
234
+ // batch-local. Each batch starts without anonymous state and publishes
235
+ // named registers only after writes land.
235
236
  const sessionClipboard = getEditClipboard(options.session);
236
- const clipboard = forkClipboard(sessionClipboard);
237
+ const clipboard = startClipboardBatch(sessionClipboard);
237
238
 
238
239
  // Single-section fast path: prepare, commit, render.
239
240
  const inputHash = hashPatchInput(options.input);
@@ -254,8 +255,8 @@ export async function executeHashlineSingle(
254
255
  }
255
256
 
256
257
  // Multi-section: prepare every section up front so we fail fast before
257
- // any write hits the filesystem. One clipboard register spans the batch,
258
- // so `CUT` in one section feeds `PASTE` in a later one.
258
+ // any write hits the filesystem. One batch-local register spans the batch,
259
+ // so `CUT` in one section feeds a register-backed `PUT` in a later one.
259
260
  const prepared: PreparedSection[] = [];
260
261
  // Register state after each section's prepare. Commits are non-atomic: a
261
262
  // mid-batch write failure leaves earlier sections on disk, so the session
@@ -140,7 +140,7 @@ interface HashlineInputEntry {
140
140
  path: string;
141
141
  op?: Operation;
142
142
  rename?: string;
143
- /** A SWAP/CUT/INS line-editing op precedes the file op — keeps a move framed. */
143
+ /** A PUT/CUT line edit precedes the file op — keeps a move framed. */
144
144
  hasLineEdits?: boolean;
145
145
  }
146
146
 
@@ -568,9 +568,9 @@ function parseHashlineInputPreviewHeader(line: string): string | null {
568
568
  return previewPath.length > 0 ? previewPath : null;
569
569
  }
570
570
 
571
- // Line-editing op headers (SWAP/CUT/INS family), distinct from file-level
571
+ // Line-editing op headers (PUT/CUT family), distinct from file-level
572
572
  // REM/MV ops. Body rows are `+TEXT`, so this only matches real headers.
573
- const HL_LINE_OP_HEADER = /^(?:SWAP|CUT|INS)\b/;
573
+ const HL_LINE_OP_HEADER = /^(?:PUT|CUT)\b/;
574
574
 
575
575
  /**
576
576
  * Walk a (possibly mid-stream) hashline payload into per-section descriptors:
@@ -313,6 +313,45 @@ export function attachSessionOwner(
313
313
  }
314
314
  }
315
315
 
316
+ /** Owner registry shared by every language's live/starting session records. */
317
+ export interface SessionOwners {
318
+ ownerIds: Set<string>;
319
+ hasFallbackOwner: boolean;
320
+ }
321
+
322
+ /**
323
+ * Resolve the session key an owner's eval cell runs on, forking `reset` away
324
+ * from shared kernels.
325
+ *
326
+ * Eval sessions are shared across agents by design (subagents inherit the
327
+ * parent's eval session id), so honoring `reset` on a co-owned kernel would
328
+ * destroy every other agent's state — including cells executing at that
329
+ * moment. When the requester does not exclusively own the live base session,
330
+ * its reset resolves to a deterministic per-owner fork key: the requester
331
+ * starts a fresh private kernel while co-owners keep the shared one. Once
332
+ * forked, the owner keeps resolving to its fork, and per-owner dispose reaps
333
+ * the fork since the requester is its only registered owner.
334
+ */
335
+ export function resolveOwnerScopedSessionKey(options: {
336
+ baseKey: string;
337
+ ownerId: string | undefined;
338
+ reset: boolean;
339
+ /** True when a live or starting session exists under `key`. */
340
+ hasSession: (key: string) => boolean;
341
+ /** Owner registry for the session under `key`, when inspectable. */
342
+ getOwners: (key: string) => SessionOwners | undefined;
343
+ }): string {
344
+ const { baseKey, ownerId } = options;
345
+ if (ownerId === undefined) return baseKey;
346
+ const forkKey = `${baseKey}\0fork\0${ownerId}`;
347
+ if (options.hasSession(forkKey)) return forkKey;
348
+ if (!options.reset) return baseKey;
349
+ const base = options.getOwners(baseKey);
350
+ if (!base) return baseKey;
351
+ const exclusive = !base.hasFallbackOwner && base.ownerIds.size === 1 && base.ownerIds.has(ownerId);
352
+ return exclusive ? baseKey : forkKey;
353
+ }
354
+
316
355
  // ---------------------------------------------------------------------------
317
356
  // Base executor implementation
318
357
  // ---------------------------------------------------------------------------
@@ -1,7 +1,12 @@
1
1
  import * as path from "node:path";
2
2
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
3
3
  import type { ToolSession } from "../../tools";
4
- import { attachSessionOwner, createCancelledKernelResult, executeWithKernelBase } from "../executor-base";
4
+ import {
5
+ attachSessionOwner,
6
+ createCancelledKernelResult,
7
+ executeWithKernelBase,
8
+ resolveOwnerScopedSessionKey,
9
+ } from "../executor-base";
5
10
  import { ensurePyToolBridge, type PyToolBridgeInfo } from "../py/tool-bridge";
6
11
  import type { EvalDisplayOutput, EvalStatusEvent } from "../types";
7
12
  import {
@@ -451,7 +456,13 @@ async function ensureToolBridge(options: JuliaExecutorOptions): Promise<void> {
451
456
 
452
457
  async function executeOnSession(code: string, cwd: string, options: JuliaExecutorOptions): Promise<JuliaResult> {
453
458
  const sessionId = options.sessionId ?? `session:${cwd}`;
454
- const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter);
459
+ const sessionKey = resolveOwnerScopedSessionKey({
460
+ baseKey: buildSessionKey(sessionId, cwd, options.interpreter),
461
+ ownerId: options.kernelOwnerId,
462
+ reset: options.reset === true,
463
+ hasSession: key => sessions.has(key) || startingSessions.has(key),
464
+ getOwners: key => sessions.get(key) ?? startingSessions.get(key),
465
+ });
455
466
  if (options.bridge && !options.bridgeSessionId) {
456
467
  options.bridgeSessionId = sessionId;
457
468
  }
@@ -8,6 +8,7 @@ import {
8
8
  import type { ToolSession } from "../../tools";
9
9
  import { ToolAbortError, ToolError } from "../../tools/tool-errors";
10
10
  import { safeSend as safeSendIpc } from "../../utils/ipc";
11
+ import { attachSessionOwner, resolveOwnerScopedSessionKey, type SessionOwners } from "../executor-base";
11
12
  import { shouldDetachKernel } from "../py/spawn-options";
12
13
  import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
13
14
  import { WorkerCore } from "./worker-core";
@@ -57,10 +58,16 @@ interface JsSession {
57
58
  worker: WorkerHandle;
58
59
  state: "alive" | "dead";
59
60
  pending: Map<string, PendingRun>;
61
+ ownerIds: Set<string>;
62
+ hasFallbackOwner: boolean;
63
+ }
64
+
65
+ interface StartingJsSession extends SessionOwners {
66
+ promise: Promise<JsSession>;
60
67
  }
61
68
 
62
69
  const sessions = new Map<string, JsSession>();
63
- const startingSessions = new Map<string, Promise<JsSession>>();
70
+ const startingSessions = new Map<string, StartingJsSession>();
64
71
  const resettingSessions = new Map<string, Promise<void>>();
65
72
  // Worker startup (module-graph import + WorkerCore construction) is infrastructure
66
73
  // cost, not user compute. Floor it independently of Bun's 5s default per-test timeout
@@ -99,6 +106,8 @@ export function setJsEvalWorkerThreadForTests(enabled: boolean): boolean {
99
106
  export async function executeInVmContext(options: {
100
107
  sessionKey: string;
101
108
  sessionId: string;
109
+ /** Logical owner identifier; scopes `reset` on shared contexts and retained-worker cleanup. */
110
+ ownerId?: string;
102
111
  cwd: string;
103
112
  session: ToolSession;
104
113
  localRoots?: Record<string, string>;
@@ -108,47 +117,55 @@ export async function executeInVmContext(options: {
108
117
  timeoutMs?: number;
109
118
  runState: VmRunState;
110
119
  }): Promise<{ value: unknown }> {
120
+ const sessionKey = resolveOwnerScopedSessionKey({
121
+ baseKey: options.sessionKey,
122
+ ownerId: options.ownerId,
123
+ reset: options.reset === true,
124
+ hasSession: key => sessions.has(key) || startingSessions.has(key),
125
+ getOwners: key => sessions.get(key) ?? startingSessions.get(key),
126
+ });
111
127
  if (options.reset) {
112
128
  // Coalesce concurrent resets: an existing in-flight reset already
113
129
  // produces a fresh context, so a follow-up `reset: true` cell should
114
130
  // just wait for it rather than failing the user-visible call.
115
- const inFlight = resettingSessions.get(options.sessionKey);
131
+ const inFlight = resettingSessions.get(sessionKey);
116
132
  if (inFlight) await inFlight.catch(() => undefined);
117
133
  else {
118
- const resetPromise = resetVmContext(options.sessionKey);
134
+ const resetPromise = resetVmContext(sessionKey);
119
135
  resettingSessions.set(
120
- options.sessionKey,
136
+ sessionKey,
121
137
  resetPromise.then(() => undefined),
122
138
  );
123
139
  try {
124
140
  await resetPromise;
125
141
  } finally {
126
- resettingSessions.delete(options.sessionKey);
142
+ resettingSessions.delete(sessionKey);
127
143
  }
128
144
  }
129
145
  } else {
130
146
  // Internal coordination: wait for any in-flight reset to settle and
131
147
  // then run on the freshly-rebuilt context.
132
- const inFlight = resettingSessions.get(options.sessionKey);
148
+ const inFlight = resettingSessions.get(sessionKey);
133
149
  if (inFlight) await inFlight.catch(() => undefined);
134
150
  }
135
151
  const session = await acquireSession(
136
- options.sessionKey,
152
+ sessionKey,
137
153
  { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots },
138
154
  options.timeoutMs,
155
+ options.ownerId,
139
156
  );
140
157
  return await runOnce(session, options);
141
158
  }
142
159
 
143
160
  export async function resetVmContext(sessionKey: string): Promise<void> {
144
- const session = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
161
+ const session = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.promise.catch(() => undefined));
145
162
  if (!session) return;
146
163
  sessions.delete(sessionKey);
147
164
  await killSession(session, new ToolError("JS context reset"), { force: false });
148
165
  }
149
166
 
150
167
  export async function disposeAllVmContexts(): Promise<void> {
151
- const pending = [...startingSessions.values()];
168
+ const pending = [...startingSessions.values()].map(starting => starting.promise);
152
169
  startingSessions.clear();
153
170
  const started = await Promise.allSettled(pending);
154
171
  const all = [...sessions.values()];
@@ -160,6 +177,45 @@ export async function disposeAllVmContexts(): Promise<void> {
160
177
  await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"), { force: false })));
161
178
  }
162
179
 
180
+ /**
181
+ * Shut down retained JS contexts owned solely by `ownerId` (e.g. a subagent's
182
+ * private fork); shared contexts just drop the owner registration.
183
+ */
184
+ export async function disposeVmContextsByOwner(ownerId: string): Promise<void> {
185
+ const toKill: JsSession[] = [];
186
+ for (const session of [...sessions.values()]) {
187
+ if (!session.ownerIds.has(ownerId)) continue;
188
+ if (session.ownerIds.size === 1) {
189
+ toKill.push(session);
190
+ continue;
191
+ }
192
+ session.ownerIds.delete(ownerId);
193
+ }
194
+ const startingToKill: StartingJsSession[] = [];
195
+ for (const [sessionKey, starting] of [...startingSessions.entries()]) {
196
+ if (sessions.has(sessionKey) || !starting.ownerIds.has(ownerId)) continue;
197
+ if (starting.ownerIds.size === 1) {
198
+ startingSessions.delete(sessionKey);
199
+ startingToKill.push(starting);
200
+ continue;
201
+ }
202
+ starting.ownerIds.delete(ownerId);
203
+ }
204
+ for (const session of toKill) {
205
+ if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
206
+ }
207
+ const started = await Promise.allSettled(startingToKill.map(starting => starting.promise));
208
+ for (const result of started) {
209
+ if (result.status !== "fulfilled") continue;
210
+ const session = result.value;
211
+ if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
212
+ toKill.push(session);
213
+ }
214
+ await Promise.all(
215
+ toKill.map(session => killSession(session, new ToolError("JS context disposed"), { force: false })),
216
+ );
217
+ }
218
+
163
219
  /**
164
220
  * Smoke probe: spawn the JS evaluator through the worker-host entry and prove
165
221
  * it answers the `init` handshake in a real isolated subprocess (not the inline
@@ -177,6 +233,8 @@ export async function smokeTestJsEvalWorker(): Promise<void> {
177
233
  worker,
178
234
  state: "alive",
179
235
  pending: new Map(),
236
+ ownerIds: new Set(),
237
+ hasFallbackOwner: false,
180
238
  };
181
239
  try {
182
240
  await initWorker(session, { cwd: process.cwd(), sessionId: "smoke" }, WORKER_INIT_TIMEOUT_MS);
@@ -243,15 +301,25 @@ async function runOnce(
243
301
  }
244
302
  }
245
303
 
246
- async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, timeoutMs?: number): Promise<JsSession> {
304
+ async function acquireSession(
305
+ sessionKey: string,
306
+ snapshot: SessionSnapshot,
307
+ timeoutMs?: number,
308
+ ownerId?: string,
309
+ ): Promise<JsSession> {
247
310
  const existing = sessions.get(sessionKey);
248
311
  if (existing && existing.state === "alive") {
249
312
  existing.sessionId = snapshot.sessionId;
250
313
  existing.cwd = snapshot.cwd;
314
+ attachSessionOwner(existing, snapshot.sessionId, ownerId);
251
315
  return existing;
252
316
  }
253
317
  const starting = startingSessions.get(sessionKey);
254
- if (starting) return await starting;
318
+ if (starting) {
319
+ attachSessionOwner(starting, snapshot.sessionId, ownerId);
320
+ return await starting.promise;
321
+ }
322
+ let startingSession!: StartingJsSession;
255
323
 
256
324
  const startup = (async (): Promise<JsSession> => {
257
325
  // Attach the message listener before sending init. Both Bun Worker messages
@@ -264,6 +332,8 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, tim
264
332
  worker,
265
333
  state: "alive",
266
334
  pending: new Map(),
335
+ ownerIds: new Set(),
336
+ hasFallbackOwner: false,
267
337
  };
268
338
  // Init headroom is the fixed infrastructure floor; the caller's per-cell timeout
269
339
  // dominates when larger so users can grant more by raising `timeout` on a cell.
@@ -293,14 +363,27 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, tim
293
363
  session.state = "alive";
294
364
  }
295
365
  }
296
- sessions.set(sessionKey, session);
366
+ session.ownerIds = new Set(startingSession.ownerIds);
367
+ session.hasFallbackOwner = startingSession.hasFallbackOwner;
368
+ // Publish only while this startup still owns the key: owner disposal or
369
+ // a concurrent dispose-all may have already reaped the starting record,
370
+ // and publishing here would resurrect a context that was just torn down.
371
+ if (startingSessions.get(sessionKey) === startingSession) {
372
+ sessions.set(sessionKey, session);
373
+ }
297
374
  return session;
298
375
  })();
299
- startingSessions.set(sessionKey, startup);
376
+ startingSession = {
377
+ ownerIds: new Set(),
378
+ hasFallbackOwner: false,
379
+ promise: startup,
380
+ };
381
+ attachSessionOwner(startingSession, snapshot.sessionId, ownerId);
382
+ startingSessions.set(sessionKey, startingSession);
300
383
  try {
301
384
  return await startup;
302
385
  } finally {
303
- if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
386
+ if (startingSessions.get(sessionKey) === startingSession) startingSessions.delete(sessionKey);
304
387
  }
305
388
  }
306
389
 
@@ -19,6 +19,8 @@ export interface JsExecutorOptions {
19
19
  onStatus?: (event: JsStatusEvent) => void;
20
20
  signal?: AbortSignal;
21
21
  sessionId: string;
22
+ /** Logical owner identifier; scopes `reset` on shared contexts and retained-worker cleanup. */
23
+ kernelOwnerId?: string;
22
24
  reset?: boolean;
23
25
  sessionFile?: string;
24
26
  artifactPath?: string;
@@ -100,6 +102,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
100
102
  await executeInVmContext({
101
103
  sessionKey: options.sessionId,
102
104
  sessionId: options.sessionId,
105
+ ownerId: options.kernelOwnerId,
103
106
  cwd: options.cwd ?? options.session.cwd,
104
107
  session: options.session,
105
108
  localRoots: options.localRoots,
@@ -28,6 +28,7 @@ export default {
28
28
  idleTimeoutMs: opts.idleTimeoutMs,
29
29
  signal: opts.signal,
30
30
  sessionId: namespaceSessionId(opts.sessionId),
31
+ kernelOwnerId: opts.kernelOwnerId,
31
32
  sessionFile: opts.sessionFile,
32
33
  reset: opts.reset,
33
34
  onChunk: opts.onChunk,