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

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 +61 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-c5hpqt9r.md} +61 -0
  3. package/dist/cli.js +3856 -3850
  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/ask-dialog.d.ts +0 -1
  18. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  19. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +2 -0
  23. package/dist/types/secrets/index.d.ts +22 -0
  24. package/dist/types/secrets/obfuscator.d.ts +1 -1
  25. package/dist/types/session/agent-session-types.d.ts +4 -0
  26. package/dist/types/session/agent-session.d.ts +9 -0
  27. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  28. package/dist/types/session/session-storage.d.ts +5 -2
  29. package/dist/types/session/session-tools.d.ts +2 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -1
  31. package/dist/types/system-prompt.d.ts +8 -0
  32. package/dist/types/task/executor.d.ts +27 -0
  33. package/dist/types/task/persisted-revive.d.ts +7 -0
  34. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  35. package/dist/types/tools/browser/attach.d.ts +9 -1
  36. package/dist/types/tools/browser/launch.d.ts +50 -1
  37. package/dist/types/tools/browser/registry.d.ts +7 -0
  38. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  39. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  40. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  42. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  43. package/dist/types/utils/image-loading.d.ts +2 -0
  44. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  45. package/dist/types/web/search/types.d.ts +2 -2
  46. package/package.json +12 -12
  47. package/src/commit/agentic/index.ts +2 -1
  48. package/src/commit/pipeline.ts +3 -2
  49. package/src/config/keybindings.ts +6 -1
  50. package/src/config/settings-schema.ts +16 -3
  51. package/src/edit/hashline/block-resolver.ts +5 -5
  52. package/src/edit/hashline/diff.ts +22 -15
  53. package/src/edit/hashline/execute.ts +14 -13
  54. package/src/edit/renderer.ts +3 -3
  55. package/src/eval/executor-base.ts +39 -0
  56. package/src/eval/jl/executor.ts +13 -2
  57. package/src/eval/js/context-manager.ts +97 -14
  58. package/src/eval/js/executor.ts +3 -0
  59. package/src/eval/js/index.ts +1 -0
  60. package/src/eval/py/executor.ts +55 -15
  61. package/src/eval/rb/executor.ts +8 -1
  62. package/src/extensibility/extensions/runner.ts +121 -4
  63. package/src/extensibility/extensions/types.ts +15 -0
  64. package/src/extensibility/extensions/wrapper.ts +19 -2
  65. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  66. package/src/launch/broker.ts +59 -41
  67. package/src/live/visualizer.ts +8 -1
  68. package/src/lsp/index.ts +51 -11
  69. package/src/main.ts +3 -0
  70. package/src/modes/acp/acp-agent.ts +16 -5
  71. package/src/modes/components/ask-dialog.ts +65 -23
  72. package/src/modes/components/assistant-message.ts +5 -10
  73. package/src/modes/components/custom-editor.ts +1 -1
  74. package/src/modes/components/keybinding-hints.ts +4 -13
  75. package/src/modes/components/status-line/segments.ts +4 -5
  76. package/src/modes/components/tool-execution.ts +4 -5
  77. package/src/modes/controllers/input-controller.ts +18 -40
  78. package/src/modes/controllers/live-command-controller.ts +1 -0
  79. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  80. package/src/modes/interactive-mode.ts +59 -2
  81. package/src/modes/theme/theme.ts +13 -6
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/prompts/system/plan-mode-active.md +5 -5
  85. package/src/sdk.ts +47 -16
  86. package/src/secrets/index.ts +83 -3
  87. package/src/secrets/obfuscator.ts +70 -15
  88. package/src/session/agent-session-types.ts +7 -1
  89. package/src/session/agent-session.ts +394 -113
  90. package/src/session/codex-auto-reset.ts +594 -123
  91. package/src/session/eval-runner.ts +2 -0
  92. package/src/session/messages.ts +1 -1
  93. package/src/session/session-maintenance.ts +50 -3
  94. package/src/session/session-manager.ts +4 -3
  95. package/src/session/session-stats.ts +14 -3
  96. package/src/session/session-storage.ts +47 -15
  97. package/src/session/session-tools.ts +30 -2
  98. package/src/session/turn-recovery.ts +15 -2
  99. package/src/slash-commands/builtin-registry.ts +21 -15
  100. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  101. package/src/system-prompt.ts +13 -1
  102. package/src/task/executor.ts +168 -15
  103. package/src/task/persisted-revive.ts +26 -1
  104. package/src/telemetry-export.ts +8 -4
  105. package/src/tools/bash-interceptor.ts +85 -8
  106. package/src/tools/bash.ts +12 -6
  107. package/src/tools/browser/attach.ts +35 -10
  108. package/src/tools/browser/launch.ts +110 -16
  109. package/src/tools/browser/registry.ts +140 -31
  110. package/src/tools/browser/shared-daemon.ts +190 -0
  111. package/src/tools/browser/tab-protocol.ts +8 -0
  112. package/src/tools/browser/tab-supervisor.ts +31 -4
  113. package/src/tools/browser/tab-worker.ts +32 -2
  114. package/src/tools/browser.ts +1 -1
  115. package/src/tools/output-meta.ts +16 -1
  116. package/src/tools/shell-tokenize.ts +101 -0
  117. package/src/utils/image-loading.ts +8 -3
  118. package/src/utils/shell-snapshot.ts +31 -21
  119. package/src/web/search/providers/codex.ts +31 -6
  120. package/src/web/search/providers/duckduckgo.ts +183 -15
  121. package/src/web/search/types.ts +6 -1
@@ -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,
@@ -13,6 +13,8 @@ import {
13
13
  getRemainingTimeoutMs,
14
14
  isCancellationError,
15
15
  isTimedOutCancellation,
16
+ resolveOwnerScopedSessionKey,
17
+ type SessionOwners,
16
18
  waitForPromiseWithCancellation,
17
19
  } from "../executor-base";
18
20
  import type { JsStatusEvent } from "../js/shared/types";
@@ -154,8 +156,12 @@ interface PythonSession {
154
156
  hasFallbackOwner: boolean;
155
157
  }
156
158
 
159
+ interface StartingPythonSession extends SessionOwners {
160
+ promise: Promise<PythonSession>;
161
+ }
162
+
157
163
  const sessions = new Map<string, PythonSession>();
158
- const startingSessions = new Map<string, Promise<PythonSession>>();
164
+ const startingSessions = new Map<string, StartingPythonSession>();
159
165
  const resettingSessions = new Map<string, Promise<void>>();
160
166
 
161
167
  function normalizeSessionCwd(cwd: string): string {
@@ -252,10 +258,10 @@ async function acquireSession(
252
258
  }
253
259
  const starting = startingSessions.get(sessionKey);
254
260
  if (starting) {
255
- const session = await starting;
256
- attachSessionOwner(session, sessionId, options.kernelOwnerId);
257
- return session;
261
+ attachSessionOwner(starting, sessionId, options.kernelOwnerId);
262
+ return await starting.promise;
258
263
  }
264
+ let startingSession!: StartingPythonSession;
259
265
  const startup = (async () => {
260
266
  const kernel = await startKernel(cwd, options);
261
267
  const session: PythonSession = {
@@ -264,19 +270,28 @@ async function acquireSession(
264
270
  cwd,
265
271
  kernel,
266
272
  generation: 0,
267
- ownerIds: new Set(),
268
- hasFallbackOwner: false,
273
+ ownerIds: new Set(startingSession.ownerIds),
274
+ hasFallbackOwner: startingSession.hasFallbackOwner,
269
275
  };
270
- sessions.set(sessionKey, session);
276
+ // Publish only while this startup still owns the key: owner disposal or
277
+ // a concurrent dispose-all may have already reaped the starting record,
278
+ // and publishing here would resurrect a kernel that was just torn down.
279
+ if (startingSessions.get(sessionKey) === startingSession) {
280
+ sessions.set(sessionKey, session);
281
+ }
271
282
  return session;
272
283
  })();
273
- startingSessions.set(sessionKey, startup);
284
+ startingSession = {
285
+ ownerIds: new Set(),
286
+ hasFallbackOwner: false,
287
+ promise: startup,
288
+ };
289
+ attachSessionOwner(startingSession, sessionId, options.kernelOwnerId);
290
+ startingSessions.set(sessionKey, startingSession);
274
291
  try {
275
- const session = await startup;
276
- attachSessionOwner(session, sessionId, options.kernelOwnerId);
277
- return session;
292
+ return await startup;
278
293
  } finally {
279
- if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
294
+ if (startingSessions.get(sessionKey) === startingSession) startingSessions.delete(sessionKey);
280
295
  }
281
296
  }
282
297
 
@@ -370,7 +385,8 @@ async function acquireLiveSessionKernel(
370
385
  }
371
386
 
372
387
  async function resetSession(sessionKey: string): Promise<void> {
373
- const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
388
+ const existing =
389
+ sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.promise.catch(() => undefined));
374
390
  if (!existing) return;
375
391
  existing.generation += 1;
376
392
  sessions.delete(sessionKey);
@@ -382,7 +398,7 @@ async function resetSession(sessionKey: string): Promise<void> {
382
398
  // ---------------------------------------------------------------------------
383
399
 
384
400
  export async function disposeAllKernelSessions(): Promise<void> {
385
- const pending = [...startingSessions.values()];
401
+ const pending = [...startingSessions.values()].map(starting => starting.promise);
386
402
  startingSessions.clear();
387
403
  const started = await Promise.allSettled(pending);
388
404
  const all = [...sessions.entries()];
@@ -422,10 +438,28 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
422
438
  }
423
439
  session.ownerIds.delete(ownerId);
424
440
  }
441
+ const startingToShutdown: StartingPythonSession[] = [];
442
+ for (const [sessionKey, starting] of [...startingSessions.entries()]) {
443
+ if (sessions.has(sessionKey) || !starting.ownerIds.has(ownerId)) continue;
444
+ if (starting.ownerIds.size === 1) {
445
+ startingSessions.delete(sessionKey);
446
+ startingToShutdown.push(starting);
447
+ continue;
448
+ }
449
+ starting.ownerIds.delete(ownerId);
450
+ }
425
451
  for (const session of toShutdown) {
426
452
  session.generation += 1;
427
453
  if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
428
454
  }
455
+ const started = await Promise.allSettled(startingToShutdown.map(starting => starting.promise));
456
+ for (const result of started) {
457
+ if (result.status !== "fulfilled") continue;
458
+ const session = result.value;
459
+ session.generation += 1;
460
+ if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
461
+ toShutdown.push(session);
462
+ }
429
463
  const results = await Promise.allSettled(toShutdown.map(session => shutdownInvalidatedSession(session)));
430
464
  for (let i = 0; i < toShutdown.length; i += 1) {
431
465
  const session = toShutdown[i];
@@ -503,7 +537,13 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
503
537
 
504
538
  async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
505
539
  const sessionId = options.sessionId ?? `session:${cwd}`;
506
- const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter);
540
+ const sessionKey = resolveOwnerScopedSessionKey({
541
+ baseKey: buildSessionKey(sessionId, cwd, options.interpreter),
542
+ ownerId: options.kernelOwnerId,
543
+ reset: options.reset === true,
544
+ hasSession: key => sessions.has(key) || startingSessions.has(key),
545
+ getOwners: key => sessions.get(key) ?? startingSessions.get(key),
546
+ });
507
547
  if (options.bridge && !options.bridgeSessionId) {
508
548
  options.bridgeSessionId = sessionId;
509
549
  }
@@ -13,6 +13,7 @@ import {
13
13
  getRemainingTimeoutMs,
14
14
  isCancellationError,
15
15
  isTimedOutCancellation,
16
+ resolveOwnerScopedSessionKey,
16
17
  waitForPromiseWithCancellation,
17
18
  } from "../executor-base";
18
19
  import type { JsStatusEvent } from "../js/shared/types";
@@ -407,7 +408,13 @@ async function ensureToolBridge(options: RubyExecutorOptions): Promise<void> {
407
408
 
408
409
  async function executeOnSession(code: string, cwd: string, options: RubyExecutorOptions): Promise<RubyResult> {
409
410
  const sessionId = options.sessionId ?? `session:${cwd}`;
410
- const sessionKey = buildSessionKey(sessionId, cwd, options.interpreter);
411
+ const sessionKey = resolveOwnerScopedSessionKey({
412
+ baseKey: buildSessionKey(sessionId, cwd, options.interpreter),
413
+ ownerId: options.kernelOwnerId,
414
+ reset: options.reset === true,
415
+ hasSession: key => sessions.has(key) || startingSessions.has(key),
416
+ getOwners: key => sessions.get(key) ?? startingSessions.get(key),
417
+ });
411
418
  if (options.bridge && !options.bridgeSessionId) {
412
419
  options.bridgeSessionId = sessionId;
413
420
  }
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Extension runner - executes extensions and manages their lifecycle.
3
3
  */
4
- import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
4
+ import type {
5
+ AgentMessage,
6
+ AgentTool,
7
+ AgentToolContext,
8
+ AgentToolResult,
9
+ AgentToolUpdateCallback,
10
+ } from "@oh-my-pi/pi-agent-core";
5
11
  import type { CredentialDisabledEvent, ImageContent, Model, ProviderResponseMetadata } from "@oh-my-pi/pi-ai";
6
12
  import type { KeyId } from "@oh-my-pi/pi-tui";
7
13
  import { logger } from "@oh-my-pi/pi-utils";
@@ -407,10 +413,72 @@ export class ExtensionRunner {
407
413
  return this.#emittedToolCalls.delete(`${toolCallId}:${toolName}`);
408
414
  }
409
415
 
416
+ /**
417
+ * Resolves a tool NAME to its native built-in implementation (the pre-extension-override,
418
+ * unwrapped tool) plus a factory for the `AgentToolContext` that native tool expects, or
419
+ * undefined when no native built-in of that name exists. Set by the SDK; backs same-tool
420
+ * `invokeTool`. The context factory is the same one the agent loop uses for tool execution, so a
421
+ * delegated native call sees the ordinary session tool context (ui, cwd, snapshot state, etc.).
422
+ */
423
+ #nativeToolResolver?: (name: string) => { tool: AgentTool; makeContext: () => AgentToolContext } | undefined;
424
+
425
+ /** Wires the native-tool resolver used by {@link invokeNativeTool}. */
426
+ setNativeToolResolver(
427
+ resolve: (name: string) => { tool: AgentTool; makeContext: () => AgentToolContext } | undefined,
428
+ ): void {
429
+ this.#nativeToolResolver = resolve;
430
+ }
431
+
432
+ /** Whether a native built-in of `name` is available to delegate to. */
433
+ hasNativeTool(name: string): boolean {
434
+ return this.#nativeToolResolver?.(name) !== undefined;
435
+ }
436
+
437
+ /**
438
+ * Run the native built-in of `name` with `params` and return its result — the delegation target
439
+ * of a same-tool `ctx.invokeTool`. Calls the unwrapped native `execute` directly with the loop's
440
+ * ordinary tool context, so it inherits the caller's already-granted approval (the caller is the
441
+ * same tool) rather than re-running the gate. `depth` guards a wrapper that recurses into itself;
442
+ * it is per call chain (threaded from the caller), not session-global, so concurrent independent
443
+ * delegations do not interfere.
444
+ */
445
+ async invokeNativeTool<TDetails = unknown>(
446
+ name: string,
447
+ params: Record<string, unknown>,
448
+ options?: {
449
+ signal?: AbortSignal;
450
+ onUpdate?: AgentToolUpdateCallback<TDetails>;
451
+ depth?: number;
452
+ /**
453
+ * The caller tool's own context. Reused for the native call so metadata the native tool
454
+ * reads — `toolCall` (write/edit LSP batch flushing) and provider metadata /
455
+ * `providerSafetyApproved` (computer) — is preserved. Falls back to a fresh session tool
456
+ * context only when the caller had none.
457
+ */
458
+ callerContext?: AgentToolContext;
459
+ },
460
+ ): Promise<AgentToolResult<TDetails>> {
461
+ const resolved = this.#nativeToolResolver?.(name);
462
+ if (!resolved) throw new Error(`invokeTool: no native built-in named "${name}" to delegate to`);
463
+ const depth = options?.depth ?? 0;
464
+ if (depth >= 8) {
465
+ throw new Error(`invokeTool: delegation depth exceeded 8 (recursive invokeTool for "${name}"?)`);
466
+ }
467
+ const toolCallId = `invoke-${name}-${Date.now().toString(36)}-${depth}`;
468
+ return (await resolved.tool.execute(
469
+ toolCallId,
470
+ params as never,
471
+ options?.signal,
472
+ options?.onUpdate as never,
473
+ options?.callerContext ?? resolved.makeContext(),
474
+ )) as AgentToolResult<TDetails>;
475
+ }
476
+
410
477
  constructor(
411
478
  private readonly extensions: Extension[],
412
479
  private readonly runtime: ExtensionRuntime,
413
- private readonly cwd: string,
480
+ /** Ignored: `cwd` is always read live via the `cwd` getter below, not cached here. */
481
+ _initialCwd: string,
414
482
  private readonly sessionManager: SessionManager,
415
483
  private readonly modelRegistry: ModelRegistry,
416
484
  getMemory?: () => MemoryRuntimeContext | undefined,
@@ -423,6 +491,24 @@ export class ExtensionRunner {
423
491
  this.#getAsyncJobSnapshotFn = getAsyncJobSnapshot ?? (() => null);
424
492
  }
425
493
 
494
+ /**
495
+ * Live session directory, not a session-start snapshot: `/move`
496
+ * (`SessionManager.moveTo()`) relocates the owning session by updating
497
+ * `sessionManager`'s own `#cwd`, not a process-global. Reading it here
498
+ * via the getter — instead of caching the constructor-time value in a
499
+ * field — keeps every `ExtensionContext` built below in sync with this
500
+ * session's actual, current directory. Deliberately `sessionManager.getCwd()`
501
+ * rather than `getProjectDir()`: the latter is a single process-wide value
502
+ * that only the interactive TUI's `/move` handler happens to also update
503
+ * (`InteractiveModeContext#applyCwdChange`) — an SDK/ACP host running
504
+ * several concurrent sessions each with their own `cwd` (see
505
+ * `CreateAgentSessionOptions.cwd`) must never have one session's move
506
+ * leak into another's `ctx.cwd` by reading a shared global.
507
+ */
508
+ get cwd(): string {
509
+ return this.sessionManager.getCwd();
510
+ }
511
+
426
512
  initialize(
427
513
  actions: ExtensionActions,
428
514
  contextActions: ExtensionContextActions,
@@ -728,8 +814,27 @@ export class ExtensionRunner {
728
814
  return undefined;
729
815
  }
730
816
 
731
- /** Creates an extension context, optionally scoped to a provider request model. */
732
- createContext(model?: Model): ExtensionContext {
817
+ /**
818
+ * Creates an extension context, optionally scoped to a provider request model.
819
+ *
820
+ * `delegation` wires the same-tool `ctx.invokeTool` for a re-registered built-in: when `toolName`
821
+ * names an existing native built-in, the context carries an `invokeTool` that runs it (see
822
+ * {@link invokeNativeTool}). The rest inherits the wrapper's own call so a bare
823
+ * `ctx.invokeTool(params)` behaves like the outer call — `context` preserves `toolCall`/provider
824
+ * metadata, `signal`/`onUpdate` default to the wrapper's own channels so aborting the outer tool
825
+ * call stops the native one and native progress still streams, and `depth` bounds recursion per
826
+ * call chain. Explicit options passed to `invokeTool` override the inherited `signal`/`onUpdate`.
827
+ */
828
+ createContext(
829
+ model?: Model,
830
+ delegation?: {
831
+ toolName: string;
832
+ depth?: number;
833
+ context?: AgentToolContext;
834
+ signal?: AbortSignal;
835
+ onUpdate?: AgentToolUpdateCallback;
836
+ },
837
+ ): ExtensionContext {
733
838
  const getModel = model ? () => model : this.#getModel;
734
839
  return {
735
840
  ui: this.#uiContext,
@@ -754,6 +859,18 @@ export class ExtensionRunner {
754
859
  setInterval: (callback, ms, ...args) => this.#managedTimers.setInterval(callback, ms, ...args),
755
860
  setTimeout: (callback, ms, ...args) => this.#managedTimers.setTimeout(callback, ms, ...args),
756
861
  clearTimer: timer => this.#managedTimers.clear(timer),
862
+ invokeTool:
863
+ delegation !== undefined && this.hasNativeTool(delegation.toolName)
864
+ ? (params, options) =>
865
+ this.invokeNativeTool(delegation.toolName, params, {
866
+ // Inherit the wrapper's own channels so a bare `ctx.invokeTool(params)` aborts
867
+ // and streams with the outer call. Explicit options win.
868
+ signal: options?.signal ?? delegation.signal,
869
+ onUpdate: options?.onUpdate ?? delegation.onUpdate,
870
+ depth: (delegation.depth ?? 0) + 1,
871
+ callerContext: delegation.context,
872
+ })
873
+ : undefined,
757
874
  };
758
875
  }
759
876
 
@@ -464,6 +464,21 @@ export interface ExtensionContext {
464
464
  setTimeout(callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]): Timer;
465
465
  /** Clear a timer scheduled via {@link setInterval} or {@link setTimeout}. */
466
466
  clearTimer(timer: Timer): void;
467
+ /**
468
+ * Run the NATIVE built-in implementation of the tool this handler re-registered, with `params`,
469
+ * and return its result. Lets a tool that re-registers a built-in (e.g. wrapping `write` to add
470
+ * logging or a policy check) delegate to the original instead of reimplementing it — the native
471
+ * tool performs its own side effects and internal bookkeeping.
472
+ *
473
+ * Delegation is same-tool only: it invokes the built-in of the SAME name as the registering tool,
474
+ * never an arbitrary target, so it cannot escalate past the approval already granted for this
475
+ * call. Present only when a native built-in of that name exists (undefined otherwise, e.g. for a
476
+ * net-new tool that shadows no built-in). Recursion is depth-guarded per call chain.
477
+ */
478
+ invokeTool?<TDetails = unknown>(
479
+ params: Record<string, unknown>,
480
+ options?: { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback<TDetails> },
481
+ ): Promise<AgentToolResult<TDetails>>;
467
482
  }
468
483
 
469
484
  /**
@@ -64,9 +64,26 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
64
64
  params: any,
65
65
  signal?: AbortSignal,
66
66
  onUpdate?: AgentToolUpdateCallback<any>,
67
- _context?: AgentToolContext,
67
+ context?: AgentToolContext,
68
68
  ) {
69
- return this.registeredTool.definition.execute(toolCallId, params, signal, onUpdate, this.runner.createContext());
69
+ // Bind the extension context to this tool's own name so `ctx.invokeTool` delegates to the
70
+ // native built-in of the same name (present only when this tool re-registers a built-in). The
71
+ // wrapper's own context, abort signal, and progress callback are inherited by the delegated
72
+ // call, so a bare `ctx.invokeTool(params)` keeps the caller's `toolCall`/provider metadata
73
+ // (write/edit LSP batching, computer safety acknowledgement), stops when the outer call is
74
+ // aborted, and still streams native progress.
75
+ return this.registeredTool.definition.execute(
76
+ toolCallId,
77
+ params,
78
+ signal,
79
+ onUpdate,
80
+ this.runner.createContext(undefined, {
81
+ toolName: this.registeredTool.definition.name,
82
+ context,
83
+ signal,
84
+ onUpdate,
85
+ }),
86
+ );
70
87
  }
71
88
  }
72
89
 
@@ -1416,11 +1416,11 @@ export function getPackageDir(): string {
1416
1416
  }
1417
1417
 
1418
1418
  // Legacy pi's `@earendil-works/pi-coding-agent` re-exported `estimateTokens`
1419
- // from its package root (via `./core/compaction/index.ts`). In omp it lives in
1420
- // `@oh-my-pi/pi-agent-core/compaction`, and the coding-agent barrel below does
1421
- // not forward it, so legacy extensions importing it fail Bun's static export
1422
- // check during validation (issue #6583).
1423
- export { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
1419
+ // and `compact` from its package root (via `./core/compaction/index.ts`). In
1420
+ // omp both live in `@oh-my-pi/pi-agent-core/compaction`, and the coding-agent
1421
+ // barrel below does not forward them, so legacy extensions importing either
1422
+ // fail Bun's static export check during validation (issues #6583, #7174).
1423
+ export { compact, estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
1424
1424
 
1425
1425
  // Same barrel gap for two more legacy package-root exports: pi re-exported the
1426
1426
  // `CONFIG_DIR_NAME` constant and the CLI parser `parseArgs`. In omp