@oh-my-pi/pi-coding-agent 17.3.0 → 17.3.1

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 (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-2swgsx9f.md} +12 -0
  3. package/dist/cli.js +3437 -3437
  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 +13 -8
  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 +125 -82
  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 +1 -0
  62. package/src/session/claude-session-store.ts +4 -3
  63. package/src/task/executor.ts +6 -3
  64. package/src/tools/browser/launch.ts +9 -0
  65. package/src/utils/git.ts +27 -0
  66. package/src/utils/shell-snapshot.ts +5 -1
@@ -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 {
@@ -5647,6 +5647,7 @@ export class AgentSession {
5647
5647
 
5648
5648
  return {
5649
5649
  ui: noOpUIContext,
5650
+ mode: "print",
5650
5651
  hasUI: false,
5651
5652
  cwd: this.sessionManager.getCwd(),
5652
5653
  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
 
@@ -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 {
@@ -2657,6 +2659,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2657
2659
  signal,
2658
2660
  onProgress,
2659
2661
  } = options;
2662
+ const cleanupGraceMs = options.cleanupGraceMs ?? TASK_ABORT_CLEANUP_GRACE_MS;
2660
2663
  const startTime = Date.now();
2661
2664
  // Set by the session's onFirstChatDispatch hook the first time the agent
2662
2665
  // loop dispatches a chat request to the provider — the launch-complete boundary.
@@ -3328,7 +3331,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3328
3331
  error = err instanceof Error ? err.stack || err.message : String(err);
3329
3332
  }
3330
3333
  } finally {
3331
- const cleanupDeadlineAt = Date.now() + TASK_ABORT_CLEANUP_GRACE_MS;
3334
+ const cleanupDeadlineAt = Date.now() + cleanupGraceMs;
3332
3335
  const cleanupChangeStatus =
3333
3336
  worktree === undefined
3334
3337
  ? "This task was not isolated, so its changes may remain in the working directory."
@@ -3339,8 +3342,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3339
3342
  lateCleanups.push(completion);
3340
3343
  exitCode = 1;
3341
3344
  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}`;
3345
+ abortReasonText = `cleanup exceeded ${cleanupGraceMs} ms`;
3346
+ error ??= `Task aborted. Cleanup did not finish within ${cleanupGraceMs} ms. ${cleanupChangeStatus}`;
3344
3347
  };
3345
3348
  if (abortSignal.aborted) {
3346
3349
  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"], {
package/src/utils/git.ts CHANGED
@@ -226,6 +226,11 @@ export const GIT_COMMAND_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
226
226
  * degrades instead of freezing the UI indefinitely.
227
227
  */
228
228
  export const GIT_SPAWN_SYNC_TIMEOUT_MS = 5_000;
229
+ /**
230
+ * Stat-poll interval for {@link head.watch}. One `stat` per interval keeps an
231
+ * always-on status line cheap while surfacing a branch switch within a second.
232
+ */
233
+ export const HEAD_WATCH_INTERVAL_MS = 1000;
229
234
 
230
235
  const GIT_COMMAND_TIMEOUT_EXIT_CODE = 124;
231
236
  // Exit code returned when the `git` binary cannot be launched at all (spawn
@@ -2296,6 +2301,28 @@ export const head = {
2296
2301
  if (result.exitCode !== 0) return null;
2297
2302
  return result.stdout.trim() || null;
2298
2303
  },
2304
+
2305
+ /**
2306
+ * Watch the repository's HEAD for branch moves. Returns a disposer.
2307
+ *
2308
+ * Deliberately stat-polls via `fs.watchFile` instead of `fs.watch`: git
2309
+ * swaps HEAD with `HEAD.lock` + atomic rename, which unlinks the HEAD inode
2310
+ * — and Bun's inotify-backed `fs.watch` permanently stops delivering events
2311
+ * after observing a rename in the watched directory (oven-sh/bun#24875), so
2312
+ * an event watcher fires once and then freezes on Linux (issue #8412 was
2313
+ * the same freeze for file-inode watches on every platform). A path-based
2314
+ * stat poll re-resolves the path each interval and survives inode swaps
2315
+ * everywhere. Reftable repos keep ref state in `<gitDir>/reftable` (their
2316
+ * HEAD file is a static stub), so the poll targets that directory instead.
2317
+ */
2318
+ watch(repository: GitRepository, onChange: () => void): () => void {
2319
+ const target = isReftableRepoSync(repository) ? path.join(repository.gitDir, "reftable") : repository.headPath;
2320
+ const listener = (curr: fs.Stats, prev: fs.Stats) => {
2321
+ if (curr.mtimeMs !== prev.mtimeMs || curr.ino !== prev.ino || curr.size !== prev.size) onChange();
2322
+ };
2323
+ fs.watchFile(target, { interval: HEAD_WATCH_INTERVAL_MS }, listener).unref();
2324
+ return () => fs.unwatchFile(target, listener);
2325
+ },
2299
2326
  };
2300
2327
 
2301
2328
  // ════════════════════════════════════════════════════════════════════════════
@@ -208,10 +208,14 @@ fi
208
208
  /**
209
209
  * Create a shell snapshot, caching the result.
210
210
  * Returns the path to the snapshot file, or null if creation failed.
211
+ *
212
+ * `timeoutMs` is configurable so callers exercising failure handling do not
213
+ * have to wait out the production startup budget.
211
214
  */
212
215
  export async function getOrCreateSnapshot(
213
216
  shell: string,
214
217
  env: Record<string, string | undefined>,
218
+ timeoutMs = SNAPSHOT_TIMEOUT_MS,
215
219
  ): Promise<string | null> {
216
220
  const cacheKey = shell;
217
221
  // Return cached snapshot if valid
@@ -284,7 +288,7 @@ export async function getOrCreateSnapshot(
284
288
  stdin: "ignore",
285
289
  stdout: "ignore",
286
290
  stderr: "ignore",
287
- timeout: SNAPSHOT_TIMEOUT_MS,
291
+ timeout: timeoutMs,
288
292
  killSignal: "SIGKILL",
289
293
  });
290
294