@oh-my-pi/pi-coding-agent 17.3.0 → 17.3.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 (70) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-fr2awajz.md} +20 -0
  3. package/dist/cli.js +2823 -2823
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/extension-flags.d.ts +3 -3
  7. package/dist/types/cli/flag-tables.d.ts +0 -1
  8. package/dist/types/cli/setup-cli.d.ts +10 -0
  9. package/dist/types/cli/update-cli.d.ts +48 -9
  10. package/dist/types/commands/completions.d.ts +3 -0
  11. package/dist/types/config/claude-paths.d.ts +7 -0
  12. package/dist/types/discovery/agents.d.ts +6 -6
  13. package/dist/types/discovery/helpers.d.ts +3 -4
  14. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  15. package/dist/types/extensibility/extensions/types.d.ts +4 -0
  16. package/dist/types/launch/broker.d.ts +5 -1
  17. package/dist/types/main.d.ts +1 -1
  18. package/dist/types/mcp/transports/stdio.d.ts +6 -3
  19. package/dist/types/modes/components/footer.d.ts +3 -2
  20. package/dist/types/modes/interactive-mode.d.ts +2 -1
  21. package/dist/types/modes/rpc/rpc-client.d.ts +2 -0
  22. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  23. package/dist/types/modes/runtime-init.d.ts +3 -1
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  25. package/dist/types/task/executor.d.ts +2 -0
  26. package/dist/types/utils/git.d.ts +19 -0
  27. package/dist/types/utils/shell-snapshot.d.ts +4 -1
  28. package/package.json +13 -13
  29. package/src/async/job-manager.ts +33 -4
  30. package/src/cli/args.ts +14 -3
  31. package/src/cli/extension-flags.ts +6 -10
  32. package/src/cli/flag-tables.ts +2 -10
  33. package/src/cli/gc-cli.ts +13 -3
  34. package/src/cli/setup-cli.ts +2 -2
  35. package/src/cli/update-cli.ts +246 -107
  36. package/src/commands/completions.ts +16 -14
  37. package/src/config/claude-paths.ts +18 -0
  38. package/src/config/model-registry.ts +2 -2
  39. package/src/config.ts +4 -3
  40. package/src/discovery/agents.ts +7 -7
  41. package/src/discovery/claude.ts +5 -6
  42. package/src/discovery/helpers.ts +12 -11
  43. package/src/extensibility/extensions/runner.ts +5 -0
  44. package/src/extensibility/extensions/types.ts +5 -0
  45. package/src/extensibility/legacy-typebox.ts +45 -4
  46. package/src/launch/broker.ts +26 -4
  47. package/src/lsp/mux/server.ts +7 -1
  48. package/src/main.ts +30 -3
  49. package/src/mcp/transports/stdio.ts +7 -3
  50. package/src/modes/acp/acp-agent.ts +1 -0
  51. package/src/modes/components/footer.ts +17 -35
  52. package/src/modes/components/status-line/component.ts +14 -27
  53. package/src/modes/controllers/extension-ui-controller.ts +2 -2
  54. package/src/modes/interactive-mode.ts +16 -9
  55. package/src/modes/print-mode.ts +1 -0
  56. package/src/modes/rpc/rpc-client.ts +4 -2
  57. package/src/modes/rpc/rpc-input.ts +27 -0
  58. package/src/modes/rpc/rpc-mode.ts +11 -19
  59. package/src/modes/runtime-init.ts +5 -1
  60. package/src/modes/utils/ui-helpers.ts +74 -53
  61. package/src/session/agent-session.ts +24 -9
  62. package/src/session/claude-session-store.ts +4 -3
  63. package/src/task/executor.ts +8 -4
  64. package/src/tools/browser/launch.ts +9 -0
  65. package/src/tools/read-format.ts +11 -10
  66. package/src/tools/run-scope.ts +4 -2
  67. package/src/utils/external-editor.ts +10 -11
  68. package/src/utils/git.ts +27 -0
  69. package/src/utils/shell-snapshot.ts +5 -1
  70. package/src/web/search/providers/gemini.ts +4 -9
@@ -283,7 +283,7 @@ export class ExtensionUiController {
283
283
  },
284
284
  };
285
285
 
286
- extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
286
+ extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
287
287
 
288
288
  // Subscribe to extension errors
289
289
  extensionRunner.onError((error: ExtensionError) => {
@@ -512,7 +512,7 @@ export class ExtensionUiController {
512
512
  },
513
513
  };
514
514
 
515
- extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
515
+ extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
516
516
  }
517
517
 
518
518
  /**
@@ -359,6 +359,20 @@ function readPersistedToolNames(value: unknown): string[] | undefined {
359
359
  return value as string[];
360
360
  }
361
361
 
362
+ export function shouldEnterPlanModeOnStartup(
363
+ sessionManager: Pick<SessionManager, "buildSessionContext" | "getEntries">,
364
+ sessionSettings: Pick<Settings, "get">,
365
+ ): boolean {
366
+ const hasConversationContext = sessionManager.buildSessionContext().messages.length > 0;
367
+ const hasExplicitMode = sessionManager.getEntries().some(entry => entry.type === "mode_change");
368
+ return (
369
+ !hasConversationContext &&
370
+ !hasExplicitMode &&
371
+ sessionSettings.get("plan.defaultOnStartup") &&
372
+ sessionSettings.get("plan.enabled")
373
+ );
374
+ }
375
+
362
376
  /** Options for creating an InteractiveMode instance (for future API use) */
363
377
  export interface InteractiveModeOptions {
364
378
  /** Providers that were migrated during startup */
@@ -1140,14 +1154,7 @@ export class InteractiveMode implements InteractiveModeContext {
1140
1154
  // execution handoff clear never get dragged back into plan mode. #enterPlanMode
1141
1155
  // is idempotent and self-guards against an already-active plan/goal mode; it
1142
1156
  // does not check plan.enabled itself.
1143
- const hasConversationContext = this.sessionManager.buildSessionContext().messages.length > 0;
1144
- const hasExplicitMode = this.sessionManager.getEntries().some(entry => entry.type === "mode_change");
1145
- const isFreshSession = !hasConversationContext && !hasExplicitMode;
1146
- if (
1147
- isFreshSession &&
1148
- this.session.settings.get("plan.defaultOnStartup") &&
1149
- this.session.settings.get("plan.enabled")
1150
- ) {
1157
+ if (shouldEnterPlanModeOnStartup(this.sessionManager, this.session.settings)) {
1151
1158
  await this.#enterPlanMode();
1152
1159
  }
1153
1160
 
@@ -4597,7 +4604,7 @@ export class InteractiveMode implements InteractiveModeContext {
4597
4604
  this.#uiHelpers.renderSessionContext(sessionContext, options);
4598
4605
  }
4599
4606
 
4600
- /** Render a session context in bounded chunks so terminal input runs between transcript paints. */
4607
+ /** Build a session context in bounded chunks so terminal input runs between event-loop turns. */
4601
4608
  async renderSessionContextIncrementally(
4602
4609
  sessionContext: SessionContext,
4603
4610
  options: RenderSessionContextOptions,
@@ -118,6 +118,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
118
118
  }
119
119
  // Set up extensions for print mode (no UI, no command context)
120
120
  await initializeExtensions(session, {
121
+ mode: mode === "json" ? "json" : "print",
121
122
  reportSendError: (action, err) => {
122
123
  process.stderr.write(
123
124
  `Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`,
@@ -62,6 +62,8 @@ export interface RpcClientOptions {
62
62
  sessionDir?: string;
63
63
  /** Additional CLI arguments */
64
64
  args?: string[];
65
+ /** Grace period before escalating process termination (default: process utility default, 1000ms) */
66
+ terminationGraceMs?: number;
65
67
  /** Custom tools owned by the embedding host and exposed over the RPC transport */
66
68
  customTools?: RpcClientCustomTool[];
67
69
  }
@@ -324,7 +326,7 @@ export class RpcClient {
324
326
  this.#pendingHostToolCalls.clear();
325
327
 
326
328
  try {
327
- child.kill();
329
+ child.kill(undefined, this.options.terminationGraceMs);
328
330
  } catch {
329
331
  // The process may already have exited.
330
332
  }
@@ -440,7 +442,7 @@ export class RpcClient {
440
442
 
441
443
  const error = new Error("Client stopped");
442
444
  const child = this.#process;
443
- child.kill();
445
+ child.kill(undefined, this.options.terminationGraceMs);
444
446
  this.#abortController.abort(error);
445
447
  this.#process = null;
446
448
  for (const request of this.#pendingRequests.values()) request.reject(error);
@@ -1,3 +1,5 @@
1
+ import { readLines } from "@oh-my-pi/pi-utils";
2
+
1
3
  /**
2
4
  * Claims Bun's singleton stdin reader immediately and exposes a separately readable stream.
3
5
  * RPC startup uses this before extension discovery so in-process modules cannot steal protocol input.
@@ -36,3 +38,28 @@ export function claimRpcInput(): ReadableStream<Uint8Array> {
36
38
  },
37
39
  });
38
40
  }
41
+
42
+ /**
43
+ * Parses newline-delimited RPC input without letting one malformed line stop
44
+ * subsequent protocol frames.
45
+ */
46
+ export async function readRpcInputFrames(
47
+ input: ReadableStream<Uint8Array>,
48
+ onFrame: (frame: unknown) => void,
49
+ onParseError: (message: string) => void,
50
+ ): Promise<void> {
51
+ const decoder = new TextDecoder();
52
+ for await (const line of readLines(input)) {
53
+ const text = decoder.decode(line).trim();
54
+ if (!text) continue;
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(text);
58
+ } catch (error: unknown) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ onParseError(`Failed to parse command: ${message}`);
61
+ continue;
62
+ }
63
+ onFrame(parsed);
64
+ }
65
+ }
@@ -13,7 +13,7 @@
13
13
  import { once } from "node:events";
14
14
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
15
15
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
16
- import { $env, isRecord, readLines, Snowflake } from "@oh-my-pi/pi-utils";
16
+ import { $env, isRecord, Snowflake } from "@oh-my-pi/pi-utils";
17
17
  import { reset as resetCapabilities } from "../../capability";
18
18
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
19
19
  import {
@@ -37,7 +37,7 @@ import { initializeExtensions } from "../runtime-init";
37
37
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
38
38
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
39
39
  import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameEncoder } from "./rpc-frame";
40
- import { claimRpcInput } from "./rpc-input";
40
+ import { claimRpcInput, readRpcInputFrames } from "./rpc-input";
41
41
  import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages";
42
42
  import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
43
43
  import type {
@@ -933,6 +933,7 @@ export async function runRpcMode(
933
933
 
934
934
  // Set up extensions with RPC-based UI context
935
935
  await initializeExtensions(session, {
936
+ mode: "rpc",
936
937
  reportSendError: (action, err) => {
937
938
  output(error(undefined, action, err.message));
938
939
  },
@@ -1484,23 +1485,14 @@ export async function runRpcMode(
1484
1485
  // Keep the stdin reader moving: side-channel frames dispatch immediately,
1485
1486
  // ordinary commands serialize through inputDispatcher, and bash remains
1486
1487
  // background-dispatched so abort_bash can overtake it. Frames are read
1487
- // line-by-line and parsed here (not via readJsonl) so a single malformed
1488
- // line is reported as an error frame and the loop keeps running instead of
1489
- // throwing out of the generator and killing the whole process (issue #5194).
1490
- const decoder = new TextDecoder();
1491
- for await (const line of readLines(input ?? Bun.stdin.stream())) {
1492
- const text = decoder.decode(line).trim();
1493
- if (!text) continue;
1494
- let parsed: unknown;
1495
- try {
1496
- parsed = JSON.parse(text);
1497
- } catch (e: unknown) {
1498
- const message = e instanceof Error ? e.message : String(e);
1499
- output(error(undefined, "parse", `Failed to parse command: ${message}`));
1500
- continue;
1501
- }
1502
- inputDispatcher.dispatch(parsed);
1503
- }
1488
+ // line-by-line by readRpcInputFrames so a single malformed line is reported
1489
+ // as an error frame and the loop keeps running instead of throwing out of
1490
+ // the reader and killing the whole process (issue #5194).
1491
+ await readRpcInputFrames(
1492
+ input ?? Bun.stdin.stream(),
1493
+ parsed => inputDispatcher.dispatch(parsed),
1494
+ message => output(error(undefined, "parse", message)),
1495
+ );
1504
1496
 
1505
1497
  // stdin closed — RPC client is gone. Fail pending side-channel requests
1506
1498
  // first so active/queued commands can settle, then drain accepted work.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
10
10
  import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
11
- import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
11
+ import type { ExtensionError, ExtensionMode, ExtensionUIContext } from "../extensibility/extensions/types";
12
12
  import type { AgentSession } from "../session/agent-session";
13
13
  import { USER_INTERRUPT_LABEL } from "../session/messages";
14
14
 
@@ -22,6 +22,8 @@ export interface InitializeExtensionsOptions {
22
22
  reportRuntimeError: (error: ExtensionError) => void;
23
23
  /** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */
24
24
  onShutdown?: () => void;
25
+ /** Pi-compatible mode exposed to extension contexts. Defaults to `"print"`. */
26
+ mode?: ExtensionMode;
25
27
  /** Optional UI context (rpc supplies one; print runs headless). */
26
28
  uiContext?: ExtensionUIContext;
27
29
  /** Optional lifecycle hook for extension-originated messages that can start an agent turn. */
@@ -44,6 +46,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
44
46
  reportSendError,
45
47
  reportRuntimeError,
46
48
  onShutdown,
49
+ mode = "print",
47
50
  uiContext,
48
51
  markAgentInvokingMessage,
49
52
  trackAgentInvokingMessage,
@@ -137,6 +140,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
137
140
  compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
138
141
  },
139
142
  uiContext,
143
+ mode,
140
144
  );
141
145
 
142
146
  runner.onError(reportRuntimeError);
@@ -33,8 +33,8 @@ import {
33
33
  import { SkillMessageComponent } from "../../modes/components/skill-message";
34
34
  import { StrippedToolCallsPlaceholder } from "../../modes/components/stripped-tool-calls-placeholder";
35
35
  import { ToolActivityContainer } from "../../modes/components/tool-activity";
36
- import { ToolExecutionComponent } from "../../modes/components/tool-execution";
37
- import { TranscriptBlock } from "../../modes/components/transcript-container";
36
+ import { ToolExecutionComponent, type ToolExecutionHandle } from "../../modes/components/tool-execution";
37
+ import { TranscriptBlock, TranscriptContainer } from "../../modes/components/transcript-container";
38
38
  import { createUsageRowBlock } from "../../modes/components/usage-row";
39
39
  import { UserMessageComponent } from "../../modes/components/user-message";
40
40
  import { decodeStreamedToolArgs, streamingStringKeysForTool } from "../../modes/controllers/tool-args-reveal";
@@ -325,7 +325,7 @@ export class UiHelpers {
325
325
  while (!steps.next().done) {}
326
326
  }
327
327
 
328
- /** Render a session context in bounded chunks so terminal input runs between transcript paints. */
328
+ /** Build a session context in bounded chunks so terminal input runs between event-loop turns. */
329
329
  async renderSessionContextIncrementally(
330
330
  sessionContext: SessionContext,
331
331
  options: RenderSessionContextOptions,
@@ -729,18 +729,23 @@ export class UiHelpers {
729
729
  }
730
730
 
731
731
  async renderInitialMessages(options: RenderInitialMessagesOptions = {}): Promise<void> {
732
- // This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
733
- // Clear existing rendered chat first to avoid duplicating the full session in the container.
734
- // On a non-preserving rebuild the existing blocks are discarded for good, so
735
- // dispose them (stopping any live timers/subscriptions) before clearing. When
736
- // preserving, the same instances are re-added below, so detach without dispose.
737
- const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
732
+ // Build against a detached container. Incremental construction still yields
733
+ // to terminal input, while paints keep using the complete visible transcript
734
+ // until the replacement is ready to swap in.
735
+ const visibleChatContainer = this.ctx.chatContainer;
736
+ const stagedChatContainer = new TranscriptContainer();
737
+ stagedChatContainer.setToolActivityVisible(!this.ctx.hideToolActivity);
738
+ const preservedChatChildren = options.preserveExistingChat ? [...visibleChatContainer.children] : undefined;
739
+ const previousTranscriptMessageComponents = this.ctx.transcriptMessageComponents;
740
+ const previousPendingTools = this.ctx.pendingTools;
741
+ const previousPendingBashComponents = this.ctx.pendingBashComponents;
742
+ const previousPendingPythonComponents = this.ctx.pendingPythonComponents;
743
+ const previousLastAssistantUsage = this.ctx.lastAssistantUsage;
738
744
  const chatWasAlreadyRendered = this.ctx.initialChatRendered;
739
- if (preservedChatChildren) {
740
- this.ctx.chatContainer.clear();
741
- } else {
742
- this.ctx.resetTranscript();
743
- }
745
+
746
+ this.ctx.chatContainer = stagedChatContainer;
747
+ this.ctx.transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
748
+ this.ctx.pendingTools = new Map<string, ToolExecutionHandle>();
744
749
  this.ctx.pendingMessagesContainer.disposeChildren();
745
750
  this.ctx.pendingBashComponents = [];
746
751
  this.ctx.pendingPythonComponents = [];
@@ -751,13 +756,6 @@ export class UiHelpers {
751
756
  // (focus attach/unfocus while a tool executes) keep dangling toolCalls so
752
757
  // the in-flight call re-renders as pending instead of vanishing;
753
758
  // renderSessionContext then keeps it in `pendingTools` for live routing.
754
- let terminalHistoryCleared = false;
755
- const renderChunk = options.clearTerminalHistory
756
- ? () => {
757
- this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared });
758
- terminalHistoryCleared = true;
759
- }
760
- : undefined;
761
759
  let context = this.ctx.viewSession.buildTranscriptSessionContext({
762
760
  collapseCompactedHistory: settings.get("display.collapseCompacted"),
763
761
  keepDanglingToolCalls: this.ctx.viewSession.isStreaming,
@@ -770,6 +768,7 @@ export class UiHelpers {
770
768
  // every attempt.
771
769
  populateHistory: false,
772
770
  };
771
+ let committed = false;
773
772
  this.ctx.initialChatRendered = false;
774
773
  try {
775
774
  while (true) {
@@ -777,8 +776,6 @@ export class UiHelpers {
777
776
  // Live events mutate the same component maps; keep their replay atomic so
778
777
  // a delta cannot land halfway through rebuilding its pending tool block.
779
778
  this.ctx.renderSessionContext(context, renderOptions);
780
- } else if (renderChunk) {
781
- await this.ctx.renderSessionContextIncrementally(context, renderOptions, renderChunk);
782
779
  } else {
783
780
  await this.ctx.renderSessionContextIncrementally(context, renderOptions);
784
781
  }
@@ -790,49 +787,73 @@ export class UiHelpers {
790
787
  // yielded. The display callback stayed gated by initialChatRendered;
791
788
  // discard the stale partial tree and replay the current session once
792
789
  // more instead of letting a reentrant synchronous rebuild interleave.
793
- this.ctx.resetTranscript();
790
+ stagedChatContainer.disposeChildren();
791
+ this.ctx.transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
792
+ this.ctx.pendingTools.clear();
794
793
  this.ctx.pendingBashComponents = [];
795
794
  this.ctx.pendingPythonComponents = [];
796
- terminalHistoryCleared = false;
797
795
  context = this.ctx.viewSession.buildTranscriptSessionContext({
798
796
  collapseCompactedHistory: settings.get("display.collapseCompacted"),
799
797
  keepDanglingToolCalls: this.ctx.viewSession.isStreaming,
800
798
  });
801
799
  replayEntryCount = this.ctx.viewSession.sessionManager.getEntries().length;
802
800
  }
803
- } finally {
804
- this.ctx.initialChatRendered = chatWasAlreadyRendered;
805
- }
806
- if (!this.ctx.focusedAgentId) {
807
- for (const message of context.messages) {
808
- if (message.role !== "user" || message.synthetic) continue;
809
- const text = this.getUserMessageText(message);
810
- if (text) this.ctx.editor.addToHistory(text);
801
+
802
+ const replayedChatChildren = [...stagedChatContainer.children];
803
+ stagedChatContainer.clear();
804
+ this.ctx.chatContainer = visibleChatContainer;
805
+ if (preservedChatChildren) {
806
+ visibleChatContainer.clear();
807
+ } else {
808
+ visibleChatContainer.disposeChildren();
811
809
  }
812
- }
810
+ for (const child of replayedChatChildren) {
811
+ visibleChatContainer.addChild(child);
812
+ }
813
+ if (preservedChatChildren) {
814
+ for (const child of preservedChatChildren) {
815
+ visibleChatContainer.addChild(child);
816
+ }
817
+ }
818
+ committed = true;
813
819
 
814
- // Show compaction info if session was compacted
815
- const allEntries = this.ctx.viewSession.sessionManager.getEntries();
816
- let compactionCount = 0;
817
- for (const entry of allEntries) {
818
- if (entry.type === "compaction") {
819
- compactionCount++;
820
+ if (!this.ctx.focusedAgentId) {
821
+ for (const message of context.messages) {
822
+ if (message.role !== "user" || message.synthetic) continue;
823
+ const text = this.getUserMessageText(message);
824
+ if (text) this.ctx.editor.addToHistory(text);
825
+ }
820
826
  }
821
- }
822
- if (compactionCount > 0) {
823
- const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
824
- this.ctx.showStatus(`Session compacted ${times}`);
825
- }
826
- if (options.clearTerminalHistory) {
827
- this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared });
828
- }
829
- if (preservedChatChildren && preservedChatChildren.length > 0) {
830
- for (const child of preservedChatChildren) {
831
- this.ctx.chatContainer.addChild(child);
827
+
828
+ // Show compaction info if session was compacted.
829
+ const allEntries = this.ctx.viewSession.sessionManager.getEntries();
830
+ let compactionCount = 0;
831
+ for (const entry of allEntries) {
832
+ if (entry.type === "compaction") {
833
+ compactionCount++;
834
+ }
832
835
  }
833
- this.ctx.ui.requestRender();
836
+ if (compactionCount > 0) {
837
+ const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
838
+ this.ctx.showStatus(`Session compacted ${times}`);
839
+ }
840
+ if (options.clearTerminalHistory) {
841
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
842
+ } else {
843
+ this.ctx.ui.requestRender();
844
+ }
845
+ } finally {
846
+ if (!committed) {
847
+ this.ctx.chatContainer = visibleChatContainer;
848
+ this.ctx.transcriptMessageComponents = previousTranscriptMessageComponents;
849
+ this.ctx.pendingTools = previousPendingTools;
850
+ this.ctx.pendingBashComponents = previousPendingBashComponents;
851
+ this.ctx.pendingPythonComponents = previousPendingPythonComponents;
852
+ this.ctx.lastAssistantUsage = previousLastAssistantUsage;
853
+ stagedChatContainer.disposeChildren();
854
+ }
855
+ this.ctx.initialChatRendered = committed ? true : chatWasAlreadyRendered;
834
856
  }
835
- this.ctx.initialChatRendered = true;
836
857
  }
837
858
 
838
859
  clearEditor(): void {
@@ -37,6 +37,7 @@ import {
37
37
  type AsideMessage,
38
38
  type BeforeToolCallContext,
39
39
  type BeforeToolCallResult,
40
+ EventLoopKeepalive,
40
41
  resolveTelemetry,
41
42
  type StreamFn,
42
43
  TERMINAL_TOOL_RESULT_ABORT_REASON,
@@ -1213,15 +1214,28 @@ export class AgentSession {
1213
1214
  }
1214
1215
  },
1215
1216
  scheduleIdleFlush: run => {
1216
- this.#schedulePostPromptTask(
1217
- async () => {
1218
- await run();
1219
- },
1220
- {
1221
- delayMs: 1,
1222
- onSkip: () => this.yieldQueue.cancelIdleFlushScheduling(),
1223
- },
1224
- );
1217
+ const keepalive = new EventLoopKeepalive();
1218
+ try {
1219
+ this.#schedulePostPromptTask(
1220
+ async () => {
1221
+ try {
1222
+ await run();
1223
+ } finally {
1224
+ keepalive[Symbol.dispose]();
1225
+ }
1226
+ },
1227
+ {
1228
+ delayMs: 1,
1229
+ onSkip: () => {
1230
+ keepalive[Symbol.dispose]();
1231
+ this.yieldQueue.cancelIdleFlushScheduling();
1232
+ },
1233
+ },
1234
+ );
1235
+ } catch (error) {
1236
+ keepalive[Symbol.dispose]();
1237
+ throw error;
1238
+ }
1225
1239
  },
1226
1240
  });
1227
1241
  this.yieldQueue.register<LaunchCompletionEntry>(LAUNCH_COMPLETION_MESSAGE_TYPE, {
@@ -5647,6 +5661,7 @@ export class AgentSession {
5647
5661
 
5648
5662
  return {
5649
5663
  ui: noOpUIContext,
5664
+ mode: "print",
5650
5665
  hasUI: false,
5651
5666
  cwd: this.sessionManager.getCwd(),
5652
5667
  sessionManager: this.sessionManager,
@@ -1,6 +1,5 @@
1
1
  import type * as fsTypes from "node:fs";
2
2
  import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
3
  import * as path from "node:path";
5
4
  import type {
6
5
  AssistantMessage,
@@ -13,6 +12,7 @@ import type {
13
12
  UserMessage,
14
13
  } from "@oh-my-pi/pi-ai";
15
14
  import { isRecord } from "@oh-my-pi/pi-utils";
15
+ import { resolveClaudePaths } from "../config/claude-paths";
16
16
  import { collectForeignJsonRecords, type ForeignJsonRecord, readForeignJsonRecords } from "./foreign-session-jsonl";
17
17
  import type { ForeignSessionInfo, ForeignSessionStore } from "./foreign-session-store";
18
18
  import type { ModelChangeEntry, SessionMessageEntry } from "./session-entries";
@@ -99,7 +99,8 @@ async function readHistoryIndex(file: string): Promise<Map<string, ClaudeHistory
99
99
  }
100
100
 
101
101
  async function readRegisteredProjects(root: string): Promise<string[]> {
102
- const config = path.join(path.dirname(root), ".claude.json");
102
+ const { configDir, configFile } = resolveClaudePaths();
103
+ const config = root === configDir ? configFile : path.join(path.dirname(root), ".claude.json");
103
104
  try {
104
105
  const parsed: unknown = await Bun.file(config).json();
105
106
  if (!isRecord(parsed) || !isRecord(parsed.projects)) return [];
@@ -306,7 +307,7 @@ export class ClaudeSessionStore implements ForeignSessionStore {
306
307
  readonly #root: string;
307
308
 
308
309
  /** Creates a store rooted at Claude's data directory, or at a fixture root when supplied. */
309
- constructor(root: string = path.join(os.homedir(), ".claude")) {
310
+ constructor(root: string = resolveClaudePaths().configDir) {
310
311
  this.#root = path.resolve(root);
311
312
  }
312
313
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
9
- import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
9
+ import { EventLoopKeepalive, recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
10
  import type { Api, Model, ServiceTierByFamily, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import { ASYNC_JOB_MANAGER_SHUTDOWN_REASON, AsyncJobManager } from "../async";
@@ -493,6 +493,8 @@ export interface ExecutorOptions {
493
493
  keepAlive?: boolean;
494
494
  /** Internal ownership handoff for cleanup that outlives the visible Task result. */
495
495
  onCleanupDeferred?: (completion: Promise<void>) => void;
496
+ /** Internal cleanup grace override for deterministic lifecycle tests. */
497
+ cleanupGraceMs?: number;
496
498
  }
497
499
 
498
500
  function parseStringifiedJson(value: unknown): unknown {
@@ -1866,6 +1868,7 @@ async function driveSessionToYield(
1866
1868
  monitor: SubagentRunMonitor,
1867
1869
  task: string,
1868
1870
  ): Promise<DriveOutcome> {
1871
+ using _keepalive = new EventLoopKeepalive();
1869
1872
  const abortSignal = monitor.abortSignal;
1870
1873
  let exitCode = 0;
1871
1874
  let error: string | undefined;
@@ -2657,6 +2660,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2657
2660
  signal,
2658
2661
  onProgress,
2659
2662
  } = options;
2663
+ const cleanupGraceMs = options.cleanupGraceMs ?? TASK_ABORT_CLEANUP_GRACE_MS;
2660
2664
  const startTime = Date.now();
2661
2665
  // Set by the session's onFirstChatDispatch hook the first time the agent
2662
2666
  // loop dispatches a chat request to the provider — the launch-complete boundary.
@@ -3328,7 +3332,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3328
3332
  error = err instanceof Error ? err.stack || err.message : String(err);
3329
3333
  }
3330
3334
  } finally {
3331
- const cleanupDeadlineAt = Date.now() + TASK_ABORT_CLEANUP_GRACE_MS;
3335
+ const cleanupDeadlineAt = Date.now() + cleanupGraceMs;
3332
3336
  const cleanupChangeStatus =
3333
3337
  worktree === undefined
3334
3338
  ? "This task was not isolated, so its changes may remain in the working directory."
@@ -3339,8 +3343,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3339
3343
  lateCleanups.push(completion);
3340
3344
  exitCode = 1;
3341
3345
  aborted = true;
3342
- abortReasonText = `cleanup exceeded ${TASK_ABORT_CLEANUP_GRACE_MS} ms`;
3343
- error ??= `Task aborted. Cleanup did not finish within ${TASK_ABORT_CLEANUP_GRACE_MS} ms. ${cleanupChangeStatus}`;
3346
+ abortReasonText = `cleanup exceeded ${cleanupGraceMs} ms`;
3347
+ error ??= `Task aborted. Cleanup did not finish within ${cleanupGraceMs} ms. ${cleanupChangeStatus}`;
3344
3348
  };
3345
3349
  if (abortSignal.aborted) {
3346
3350
  aborted = monitor.isAbortedRun();
@@ -204,6 +204,15 @@ function isExecutableFile(p: string): boolean {
204
204
 
205
205
  async function isChromiumExecutable(p: string): Promise<boolean> {
206
206
  if (!isExecutableFile(p)) return false;
207
+ // The version probe below launches the candidate. It exists to reject
208
+ // non-Chromium `chrome`/`chromium` wrapper scripts that appear on a Linux
209
+ // PATH (ecb22957, "validate Linux browser executables"). On Windows and
210
+ // macOS the candidates are fixed GUI application paths, not PATH wrappers,
211
+ // and executing them is harmful: a GUI `chrome.exe --version` does not print
212
+ // to a detached stdout and can hand off to the user's running instance,
213
+ // opening/activating a normal browser window (#8445). Confine the probe to
214
+ // Linux and trust the executable-file check elsewhere.
215
+ if (process.platform !== "linux") return true;
207
216
  try {
208
217
  const probeTimeoutMs = 3000;
209
218
  const proc = Bun.spawn([p, "--version"], {
@@ -37,16 +37,17 @@ export interface HashlineHeaderContext {
37
37
  }
38
38
 
39
39
  export function formatReadHashlineHeader(displayPath: string, tag: string): string {
40
- // In-workspace reads collapse to the bare filename for brevity: the edit
41
- // tool's snapshot-tag recovery rebinds a bare `[name#tag]` onto the in-tree
42
- // file it uniquely names. Out-of-workspace reads can't lean on that
43
- // recovery refuses to redirect a write outside the cwd/sandbox
44
- // (HashlineFilesystem.allowTagPathRecovery) so an absolute displayPath
45
- // must stay directly resolvable, otherwise the basename resolves against
46
- // cwd, misses, and the edit fails with "File not found" (e.g. ~/.claude/*).
47
- // `shortenPath` keeps `~/.claude/...` (round-trips through resolveToCwd's ~
48
- // expansion) instead of leaking the full home path into the read output.
49
- const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : path.basename(displayPath);
40
+ // In-workspace reads keep their workspace-relative path (e.g.
41
+ // `src/settings.json`), not just the basename: collapsing to the bare name
42
+ // made a header ambiguous whenever another same-named file exists at cwd
43
+ // the edit tool would resolve the bare name against cwd, hit the wrong
44
+ // file, and reject the valid edit via the snapshot-tag guard (the authored
45
+ // path exists, so Patcher's tag-path recovery never runs). The relative
46
+ // path stays directly resolvable against cwd and names the file uniquely.
47
+ // Out-of-workspace reads use an absolute displayPath; `shortenPath` keeps
48
+ // `~/.claude/...` (round-trips through resolveToCwd's ~ expansion) instead
49
+ // of leaking the full home path into the read output.
50
+ const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : displayPath;
50
51
  return formatHashlineHeader(anchor, tag);
51
52
  }
52
53
 
@@ -38,7 +38,7 @@ interface ObservedPromiseState {
38
38
  const observedBrowserPromises = new WeakMap<Promise<unknown>, ObservedPromiseState>();
39
39
  const observedPromiseConstructor = { [Symbol.species]: Promise };
40
40
 
41
- type PromiseCombinatorName = "all" | "race";
41
+ type PromiseCombinatorName = "all" | "race" | "allSettled" | "any";
42
42
  type PromiseCombinator = (this: PromiseConstructor, values: Iterable<unknown>) => Promise<unknown>;
43
43
 
44
44
  interface PromiseCombinatorTrackingContext {
@@ -46,11 +46,13 @@ interface PromiseCombinatorTrackingContext {
46
46
  onFloatingRejection: FloatingRejectionHandler;
47
47
  }
48
48
 
49
- const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race"];
49
+ const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race", "allSettled", "any"];
50
50
  const NativePromise = Promise;
51
51
  const nativePromiseCombinators: Record<PromiseCombinatorName, PromiseCombinator> = {
52
52
  all: Promise.all,
53
53
  race: Promise.race,
54
+ allSettled: Promise.allSettled,
55
+ any: Promise.any,
54
56
  };
55
57
  const promiseCombinatorTracking = new AsyncLocalStorage<PromiseCombinatorTrackingContext>();
56
58
  let previousPromiseDescriptor: PropertyDescriptor | undefined;