@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
@@ -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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type Component,
3
+ type KeyId,
3
4
  matchesKey,
4
5
  replaceTabs,
5
6
  sliceWithWidth,
@@ -15,6 +16,8 @@ export type LivePhase = "connecting" | "listening" | "working" | "speaking" | "m
15
16
  export interface LiveVisualizerOptions {
16
17
  onStop(): void;
17
18
  onToggleMute(): void;
19
+ /** Configured `app.live.toggle` chords that also end the call (Ctrl+L by default). */
20
+ stopKeys?: readonly KeyId[];
18
21
  }
19
22
 
20
23
  function normalizeTranscript(text: string): string {
@@ -100,7 +103,11 @@ export class LiveVisualizer implements Component {
100
103
 
101
104
  /** Processes user keypresses. */
102
105
  handleInput(data: string): void {
103
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
106
+ if (
107
+ matchesKey(data, "escape") ||
108
+ matchesKey(data, "ctrl+c") ||
109
+ this.#options.stopKeys?.some(key => matchesKey(data, key))
110
+ ) {
104
111
  this.#options.onStop();
105
112
  } else if (matchesKey(data, "space")) {
106
113
  this.#options.onToggleMute();
package/src/lsp/index.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  AgentToolUpdateCallback,
8
8
  ToolApprovalDecision,
9
9
  } from "@oh-my-pi/pi-agent-core";
10
- import { logger, once, prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
+ import { isEnoent, logger, once, prompt, untilAborted } from "@oh-my-pi/pi-utils";
11
11
  import type { BunFile } from "bun";
12
12
  import { type Theme, theme } from "../modes/theme/theme";
13
13
  import lspDescription from "../prompts/tools/lsp.md" with { type: "text" };
@@ -1114,11 +1114,14 @@ export async function writethroughNoop(
1114
1114
 
1115
1115
  interface PendingWritethrough {
1116
1116
  dst: string;
1117
- content: string;
1118
1117
  file?: BunFile;
1119
1118
  changeType: FileChangeType;
1120
1119
  }
1121
1120
 
1121
+ interface RunLspWritethroughOptions {
1122
+ contentAlreadyWritten?: boolean;
1123
+ }
1124
+
1122
1125
  interface LspWritethroughBatchRequest {
1123
1126
  id: string;
1124
1127
  flush: boolean;
@@ -1317,12 +1320,16 @@ async function runLspWritethrough(
1317
1320
  onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
1318
1321
  signal: AbortSignal;
1319
1322
  },
1323
+ runOptions?: RunLspWritethroughOptions,
1320
1324
  ): Promise<FileDiagnosticsResult | undefined> {
1321
1325
  const { enableFormat, enableDiagnostics } = options;
1326
+ const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false;
1322
1327
 
1323
1328
  let finalContent = content;
1324
1329
  const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
1325
- const getWritePromise = once(() => writeContent(finalContent));
1330
+ const getWritePromise = once(() =>
1331
+ contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent),
1332
+ );
1326
1333
  let writeNotified = false;
1327
1334
  const notifyWriteCommitted = async (notifySignal: AbortSignal | undefined = signal) => {
1328
1335
  if (writeNotified) return;
@@ -1383,7 +1390,7 @@ async function runLspWritethrough(
1383
1390
  if (useCustomFormatter) {
1384
1391
  // Custom linters operate on on-disk input; the shared pre-write also
1385
1392
  // supports implementations that inspect the file before formatting.
1386
- await writeContent(content);
1393
+ if (!contentAlreadyWritten) await writeContent(content);
1387
1394
  const [formattedContent, capturedVersions] = await Promise.all([
1388
1395
  formatContent(dst, content, cwd, customLinterServers, operationSignal),
1389
1396
  minVersionsPromise,
@@ -1391,7 +1398,7 @@ async function runLspWritethrough(
1391
1398
  finalContent = formattedContent;
1392
1399
  minVersions = capturedVersions;
1393
1400
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
1394
- await writeContent(finalContent);
1401
+ if (!contentAlreadyWritten || finalContent !== content) await writeContent(finalContent);
1395
1402
  await notifyWriteCommitted(operationSignal);
1396
1403
  await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal, enableDiagnostics);
1397
1404
  } else {
@@ -1485,6 +1492,14 @@ async function flushWritethroughBatch(
1485
1492
  const results: Array<FileDiagnosticsResult | undefined> = [];
1486
1493
  for (const entry of batch) {
1487
1494
  const bundle = getDeferred?.(entry.dst);
1495
+ let content: string;
1496
+ try {
1497
+ content = await fs.promises.readFile(entry.dst, "utf8");
1498
+ } catch (error) {
1499
+ if (!isEnoent(error)) throw error;
1500
+ bundle?.finalize(undefined);
1501
+ continue;
1502
+ }
1488
1503
  const deferredInner =
1489
1504
  bundle &&
1490
1505
  ({
@@ -1493,13 +1508,14 @@ async function flushWritethroughBatch(
1493
1508
  } as const);
1494
1509
  const diag = await runLspWritethrough(
1495
1510
  entry.dst,
1496
- entry.content,
1511
+ content,
1497
1512
  cwd,
1498
1513
  options,
1499
1514
  entry.changeType,
1500
1515
  signal,
1501
1516
  entry.file,
1502
1517
  deferredInner,
1518
+ { contentAlreadyWritten: true },
1503
1519
  );
1504
1520
  bundle?.finalize(diag);
1505
1521
  results.push(diag);
@@ -1545,14 +1561,38 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
1545
1561
  return diagnostics;
1546
1562
  }
1547
1563
 
1548
- const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
1549
- state.entries.set(dst, { dst, content, file, changeType });
1550
-
1551
- if (!batch.flush) {
1564
+ // File commits are never deferred: the batch owns only LSP post-processing,
1565
+ // so a later flush cannot replay an obsolete whole-file snapshot.
1566
+ try {
1552
1567
  await writethroughNoop(dst, content, signal, file);
1553
- return undefined;
1568
+ } catch (error) {
1569
+ if (batch.flush) {
1570
+ const pending = writethroughBatches.get(batch.id);
1571
+ if (pending) {
1572
+ writethroughBatches.delete(batch.id);
1573
+ try {
1574
+ await flushWritethroughBatch(
1575
+ Array.from(pending.entries.values()),
1576
+ cwd,
1577
+ pending.options,
1578
+ signal,
1579
+ getDeferred,
1580
+ );
1581
+ } catch (flushError) {
1582
+ logger.warn("Failed to flush pending LSP batch after final write failure", {
1583
+ batchId: batch.id,
1584
+ error: flushError instanceof Error ? flushError.message : String(flushError),
1585
+ });
1586
+ }
1587
+ }
1588
+ }
1589
+ throw error;
1554
1590
  }
1555
1591
 
1592
+ const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
1593
+ state.entries.set(dst, { dst, file, changeType });
1594
+ if (!batch.flush) return undefined;
1595
+
1556
1596
  writethroughBatches.delete(batch.id);
1557
1597
  return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal, getDeferred);
1558
1598
  };
package/src/main.ts CHANGED
@@ -504,6 +504,7 @@ async function runInteractiveMode(
504
504
  }
505
505
 
506
506
  if (initialMessage !== undefined) {
507
+ session.maybeStartTitleGeneration(initialMessage);
507
508
  try {
508
509
  using _keepalive = new EventLoopKeepalive();
509
510
  await session.prompt(initialMessage, { images: initialImages });
@@ -514,6 +515,7 @@ async function runInteractiveMode(
514
515
  }
515
516
 
516
517
  for (const message of initialMessages) {
518
+ session.maybeStartTitleGeneration(message);
517
519
  try {
518
520
  using _keepalive = new EventLoopKeepalive();
519
521
  await session.prompt(message);
@@ -1608,6 +1610,7 @@ export async function runRootCommand(
1608
1610
  modelRegistry,
1609
1611
  settings: settingsInstance,
1610
1612
  enableLsp: sessionOptions.enableLsp ?? true,
1613
+ eventBus,
1611
1614
  }),
1612
1615
  Math.trunc(Number(settingsInstance.get("task.agentIdleTtlMs") ?? 420_000) || 0),
1613
1616
  );
@@ -296,7 +296,7 @@ function buildAcpSpeechModelsCatalog(): Record<string, unknown> {
296
296
  async function elicitFromAcpClient(
297
297
  connection: AgentSideConnection,
298
298
  sessionId: string,
299
- method: "select" | "confirm" | "input",
299
+ method: "select" | "confirm" | "input" | "editor",
300
300
  message: string,
301
301
  property: ElicitationPropertySchema,
302
302
  dialogOptions: ExtensionUIDialogOptions | undefined,
@@ -377,9 +377,9 @@ function isAcceptedElicitation(
377
377
  * symmetric with every other `sessionUpdate` call in this file
378
378
  * (`record.session.sessionId` is always evaluated at emit time).
379
379
  *
380
- * The non-elicitation surface (custom components, editor, theming,
381
- * terminal input) remains stubbed — ACP clients render those themselves
382
- * or not at all. Capability gating respects the client's `initialize`
380
+ * The non-elicitation surface (custom components, theming, terminal
381
+ * input) remains stubbed — ACP clients render those themselves or not
382
+ * at all. Capability gating respects the client's `initialize`
383
383
  * advertisement.
384
384
  */
385
385
  export function createAcpExtensionUiContext(
@@ -443,7 +443,18 @@ export function createAcpExtensionUiContext(
443
443
  pasteToEditor: () => {},
444
444
  setEditorText: () => {},
445
445
  getEditorText: () => "",
446
- editor: async () => undefined,
446
+ editor: async (title, prefill, dialogOptions) => {
447
+ if (!supportsForm) return undefined;
448
+ const value = await elicitFromAcpClient(
449
+ connection,
450
+ getSessionId(),
451
+ "editor",
452
+ title,
453
+ { type: "string", ...(prefill ? { default: prefill } : {}) },
454
+ dialogOptions,
455
+ );
456
+ return typeof value === "string" ? value : undefined;
457
+ },
447
458
  addAutocompleteProvider: () => {},
448
459
  setEditorComponent: () => {},
449
460
  get theme() {
@@ -15,6 +15,7 @@ import chalk from "chalk";
15
15
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
16
16
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
17
17
  import { expandKeyHint, getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
18
+ import { convertImageToPng } from "../../utils/image-loading";
18
19
  import { canonicalizeMessage, formatThinkingForDisplay, hasDisplayableThinking } from "../../utils/thinking-display";
19
20
  import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers";
20
21
  import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
@@ -607,16 +608,10 @@ export class AssistantMessageComponent extends Container {
607
608
  if (image.mimeType === "image/png") continue;
608
609
  if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
609
610
  this.#kittyConversionsInFlight.add(key);
610
- new Bun.Image(Buffer.from(image.data, "base64"))
611
- .png()
612
- .toBase64()
613
- .then(data => {
611
+ convertImageToPng(image)
612
+ .then(converted => {
614
613
  this.#kittyConversionsInFlight.delete(key);
615
- this.#convertedKittyImages.set(key, {
616
- type: "image",
617
- data,
618
- mimeType: "image/png",
619
- });
614
+ this.#convertedKittyImages.set(key, converted);
620
615
  if (this.#lastMessage) {
621
616
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
622
617
  }
@@ -871,7 +866,7 @@ export class AssistantMessageComponent extends Container {
871
866
  // Set paddingY=0 to avoid extra spacing before tool executions
872
867
  const trimmed = content.text.trim();
873
868
  const mdOptions = this.#textColorTransform ? { color: this.#textColorTransform } : undefined;
874
- const md = new Markdown(trimmed, 1, 0, getMarkdownTheme(), mdOptions);
869
+ const md = new Markdown(trimmed, 1, 0, getMarkdownTheme(), mdOptions, 0);
875
870
  this.#contentContainer.addChild(md);
876
871
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
877
872
  hasRenderedContent = true;
@@ -46,7 +46,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
46
46
  "app.clear": ["ctrl+c"],
47
47
  "app.exit": ["ctrl+d"],
48
48
  "app.suspend": ["ctrl+z"],
49
- "app.display.reset": ["ctrl+l"],
49
+ "app.display.reset": ["alt+l"],
50
50
  "app.thinking.cycle": ["shift+tab"],
51
51
  "app.model.cycleForward": ["ctrl+p"],
52
52
  "app.model.cycleBackward": ["shift+ctrl+p"],