@oh-my-pi/pi-coding-agent 15.8.0 → 15.8.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.
@@ -19,6 +19,34 @@ import type {
19
19
  import { toJsonRpcError } from "../../mcp/types";
20
20
  import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
21
21
 
22
+ /** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
23
+ interface FrameSink {
24
+ write(chunk: string): unknown;
25
+ flush(): unknown;
26
+ }
27
+
28
+ /**
29
+ * Write a newline-delimited JSON-RPC frame to the subprocess's stdin sink,
30
+ * swallowing synchronous errors so the caller can decide how to react.
31
+ *
32
+ * Bun's `FileSink` may throw synchronously (most reliably on Windows) when
33
+ * the read end of the pipe has been closed by a subprocess that exited
34
+ * between read-loop ticks. Letting that throw escape an `async` method
35
+ * surfaces as an unhandled promise rejection at the call site.
36
+ *
37
+ * Returns `true` when the frame was accepted by the sink, `false` when the
38
+ * sink threw — callers signal transport closure on `false`.
39
+ */
40
+ export function writeFrame(stdin: FrameSink, frame: string): boolean {
41
+ try {
42
+ stdin.write(frame);
43
+ stdin.flush();
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
22
50
  /**
23
51
  * Stdio transport for MCP servers.
24
52
  * Spawns a subprocess and communicates via stdin/stdout.
@@ -162,11 +190,7 @@ export class StdioTransport implements MCPTransport {
162
190
  const result = await this.onRequest(request.method, request.params);
163
191
  this.#sendResponse(request.id, result);
164
192
  } catch (error) {
165
- try {
166
- this.#sendResponse(request.id, undefined, toJsonRpcError(error));
167
- } catch {
168
- // Best-effort — process may have exited
169
- }
193
+ this.#sendResponse(request.id, undefined, toJsonRpcError(error));
170
194
  }
171
195
  }
172
196
 
@@ -175,8 +199,9 @@ export class StdioTransport implements MCPTransport {
175
199
  const response = error
176
200
  ? { jsonrpc: "2.0" as const, id, error }
177
201
  : { jsonrpc: "2.0" as const, id, result: result ?? {} };
178
- this.#process.stdin.write(`${JSON.stringify(response)}\n`);
179
- this.#process.stdin.flush();
202
+ // Silent on failure — a dead subprocess has no use for the response,
203
+ // and the read loop will close the transport on EOF.
204
+ writeFrame(this.#process.stdin, `${JSON.stringify(response)}\n`);
180
205
  }
181
206
 
182
207
  #handleClose(): void {
@@ -286,35 +311,43 @@ export class StdioTransport implements MCPTransport {
286
311
  params: params ?? {},
287
312
  };
288
313
 
289
- const message = `${JSON.stringify(notification)}\n`;
290
- // Bun's FileSink has write() method directly
291
- this.#process.stdin.write(message);
292
- this.#process.stdin.flush();
314
+ // Bun's FileSink can throw EPIPE synchronously on Windows when the
315
+ // subprocess has exited between the last read-loop tick and this
316
+ // write (e.g. an MCP server that dies after returning `initialize`
317
+ // but before `notifications/initialized` is delivered). Tear the
318
+ // transport down so any wired `onClose` (and reconnect machinery)
319
+ // engages, then surface the failure to the caller so a write that
320
+ // dropped on the floor is never silently treated as delivered —
321
+ // `initializeConnection()` runs before the manager installs its
322
+ // `onClose` handler, so a swallowed failure there would yield a
323
+ // "connected" handle wrapping a dead transport. See #1710.
324
+ if (!writeFrame(this.#process.stdin, `${JSON.stringify(notification)}\n`)) {
325
+ this.#handleClose();
326
+ throw new Error(`Transport closed while sending notification "${method}"`);
327
+ }
293
328
  }
294
329
 
295
330
  async close(): Promise<void> {
296
- if (!this.#connected) return;
297
- this.#connected = false;
298
-
299
- // Reject pending requests
300
- for (const [, pending] of this.#pendingRequests) {
301
- pending.reject(new Error("Transport closed"));
331
+ // `close()` is the authoritative resource teardown. `#handleClose()`
332
+ // may have already run (read-loop EOF, or a notify() write failure
333
+ // that surfaces the dead transport to the caller) and flipped
334
+ // `#connected` to false — but the subprocess and read loop are still
335
+ // alive in that path, so we MUST keep cleaning up regardless. Each
336
+ // step is individually guarded so this remains idempotent across
337
+ // repeat calls.
338
+ if (this.#connected) {
339
+ this.#handleClose();
302
340
  }
303
- this.#pendingRequests.clear();
304
341
 
305
- // Kill subprocess
306
342
  if (this.#process) {
307
343
  this.#process.kill();
308
344
  this.#process = null;
309
345
  }
310
346
 
311
- // Wait for read loop to finish
312
347
  if (this.#readLoop) {
313
348
  await this.#readLoop.catch(() => {});
314
349
  this.#readLoop = null;
315
350
  }
316
-
317
- this.onClose?.();
318
351
  }
319
352
  }
320
353
 
@@ -59,12 +59,12 @@ output:
59
59
  Identify bugs the author would want fixed before merge.
60
60
 
61
61
  <procedure>
62
- 1. Run `git diff` (or `gh pr diff <number>`) to view patch
62
+ 1. Run `git diff`, `jj diff --git`, or `gh pr diff <number>` to view patch
63
63
  2. Read modified files for full context
64
64
  3. Call `report_finding` per issue
65
65
  4. Call `yield` with verdict
66
66
 
67
- Bash is read-only: `git diff`, `git log`, `git show`, `gh pr diff`. You NEVER make file edits or trigger builds.
67
+ Bash is read-only: `git diff`, `git log`, `git show`, `jj diff --git`, `gh pr diff`. You NEVER make file edits or trigger builds.
68
68
  </procedure>
69
69
 
70
70
  <criteria>
@@ -36,7 +36,7 @@ Group files by locality, e.g.:
36
36
 
37
37
  Reviewer MUST:
38
38
  1. Focus ONLY on assigned files
39
- 2. {{#if skipDiff}}MUST run `git diff`/`git show` for assigned files{{else}}MUST use diff hunks below (NEVER re-run git diff){{/if}}
39
+ 2. {{#if skipDiff}}{{diffInstruction}}{{else}}MUST use diff hunks below (NEVER re-run git diff){{/if}}
40
40
  3. MAY read full file context as needed via `read`
41
41
  4. Call `report_finding` per issue
42
42
  5. Call `yield` with verdict when done
@@ -0,0 +1,6 @@
1
+ <system-reminder>
2
+ The previous assistant turn ended with no text, reasoning, or tool call.
3
+ Continue the active task from the current context. If the work is complete, reply with a concise final summary instead of an empty response.
4
+
5
+ (Empty response retry {{retryCount}}/{{maxRetries}})
6
+ </system-reminder>
@@ -1,8 +1,15 @@
1
1
  Search hidden tool metadata to discover and activate tools.
2
2
 
3
3
  Activate hidden tools (MCP and built-in) when you need a capability not in your active tool set.
4
- {{#if hasDiscoverableMCPServers}}Discoverable MCP servers in this session: {{#list discoverableMCPServerSummaries join=", "}}{{this}}{{/list}}.{{/if}}
5
- {{#if discoverableMCPToolCount}}Total discoverable tools available: {{discoverableMCPToolCount}}.{{/if}}
4
+ {{#if hasDiscoverableMCPServers}}
5
+ Discoverable MCP servers in this session: {{#list discoverableMCPServerSummaries join=", "}}{{this}}{{/list}}.
6
+ {{/if}}
7
+ {{#if hasDiscoverableBuiltinTools}}
8
+ Discoverable built-in tools: {{#list discoverableBuiltinToolNames join=", "}}{{this}}{{/list}}.
9
+ {{/if}}
10
+ {{#if discoverableToolCount}}
11
+ Total discoverable tools available: {{discoverableToolCount}}.
12
+ {{/if}}
6
13
  Input:
7
14
  - `query` — required natural-language or keyword query
8
15
  - `limit` — optional maximum number of tools to return and activate (default `8`)
@@ -166,6 +166,7 @@ import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
166
166
  import type { PlanModeState } from "../plan-mode/state";
167
167
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
168
168
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
169
+ import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
169
170
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
170
171
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
171
172
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
@@ -275,6 +276,8 @@ export type AgentSessionEvent =
275
276
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
276
277
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
277
278
 
279
+ const EMPTY_STOP_MAX_RETRIES = 3;
280
+
278
281
  export interface AsyncJobSnapshot {
279
282
  running: AsyncJobSnapshotItem[];
280
283
  recent: AsyncJobSnapshotItem[];
@@ -995,6 +998,7 @@ export class AgentSession {
995
998
  #checkpointState: CheckpointState | undefined = undefined;
996
999
  #pendingRewindReport: string | undefined = undefined;
997
1000
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1001
+ #emptyStopRetryCount = 0;
998
1002
  #promptGeneration = 0;
999
1003
  #providerSessionState = new Map<string, ProviderSessionState>();
1000
1004
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
@@ -1600,17 +1604,19 @@ export class AgentSession {
1600
1604
  if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
1601
1605
  const assistantEvent = event.assistantMessageEvent;
1602
1606
  let matchContext: TtsrMatchContext | undefined;
1607
+ let streamingToolCall: ToolCall | undefined;
1603
1608
 
1604
1609
  if (assistantEvent.type === "text_delta") {
1605
1610
  matchContext = { source: "text" };
1606
1611
  } else if (assistantEvent.type === "thinking_delta") {
1607
1612
  matchContext = { source: "thinking" };
1608
1613
  } else if (assistantEvent.type === "toolcall_delta") {
1609
- matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
1614
+ streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
1615
+ matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
1610
1616
  }
1611
1617
 
1612
1618
  if (matchContext && "delta" in assistantEvent) {
1613
- const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
1619
+ const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
1614
1620
  if (matches.length > 0) {
1615
1621
  // Decide first: a non-interrupting tool-source match attaches to the
1616
1622
  // specific tool call's result instead of driving a loop-wide follow-up.
@@ -1769,6 +1775,7 @@ export class AgentSession {
1769
1775
  if (
1770
1776
  assistantMsg.stopReason !== "error" &&
1771
1777
  assistantMsg.stopReason !== "aborted" &&
1778
+ !this.#isEmptyAssistantStop(assistantMsg) &&
1772
1779
  this.#retryAttempt > 0
1773
1780
  ) {
1774
1781
  if (this.#activeRetryFallback && this.model) {
@@ -1883,6 +1890,10 @@ export class AgentSession {
1883
1890
  }
1884
1891
  this.#lastSuccessfulYieldToolCallId = undefined;
1885
1892
 
1893
+ if (await this.#handleEmptyAssistantStop(msg)) {
1894
+ return;
1895
+ }
1896
+
1886
1897
  // Check for retryable errors first (overloaded, rate limit, server errors)
1887
1898
  if (this.#isRetryableError(msg)) {
1888
1899
  const didRetry = await this.#handleRetryableError(msg);
@@ -2276,30 +2287,64 @@ export class AgentSession {
2276
2287
  });
2277
2288
  }
2278
2289
 
2279
- /** Build TTSR match context for tool call argument deltas. */
2280
- #getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
2281
- const context: TtsrMatchContext = { source: "tool" };
2290
+ /** Extract the tool-call block a toolcall_delta event refers to, if present. */
2291
+ #getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
2282
2292
  if (message.role !== "assistant") {
2283
- return context;
2293
+ return undefined;
2284
2294
  }
2285
2295
 
2286
2296
  const content = message.content;
2287
2297
  if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
2288
- return context;
2298
+ return undefined;
2289
2299
  }
2290
2300
 
2291
2301
  const block = content[contentIndex];
2292
2302
  if (!block || typeof block !== "object" || block.type !== "toolCall") {
2303
+ return undefined;
2304
+ }
2305
+
2306
+ return block as ToolCall;
2307
+ }
2308
+
2309
+ /** Build TTSR match context for tool call argument deltas. */
2310
+ #getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
2311
+ const context: TtsrMatchContext = { source: "tool" };
2312
+ if (!toolCall) {
2293
2313
  return context;
2294
2314
  }
2295
2315
 
2296
- const toolCall = block as ToolCall;
2297
2316
  context.toolName = toolCall.name;
2298
2317
  context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
2299
2318
  context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
2300
2319
  return context;
2301
2320
  }
2302
2321
 
2322
+ /**
2323
+ * Match a stream delta against TTSR rules.
2324
+ *
2325
+ * Tool argument streams prefer the tool's `matcherDigest` normalization — the
2326
+ * real content the call introduces — over the raw argument delta, so rule
2327
+ * conditions written against source text keep working regardless of the
2328
+ * tool's wire format (hashline patches, JSON-escaped strings, ...).
2329
+ */
2330
+ #checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
2331
+ const manager = this.#ttsrManager;
2332
+ if (!manager) {
2333
+ return [];
2334
+ }
2335
+ if (toolCall) {
2336
+ const tools = this.agent.state.tools;
2337
+ const tool =
2338
+ tools.find(t => t.name === toolCall.name) ??
2339
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
2340
+ const digest = tool?.matcherDigest?.(toolCall.arguments ?? {});
2341
+ if (digest !== undefined) {
2342
+ return manager.checkSnapshot(digest, matchContext);
2343
+ }
2344
+ }
2345
+ return manager.checkDelta(delta, matchContext);
2346
+ }
2347
+
2303
2348
  /** Extract path-like arguments from tool call payload for TTSR glob matching. */
2304
2349
  #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
2305
2350
  if (!args || typeof args !== "object" || Array.isArray(args)) {
@@ -4274,6 +4319,7 @@ export class AgentSession {
4274
4319
 
4275
4320
  // Reset todo reminder count on new user prompt
4276
4321
  this.#todoReminderCount = 0;
4322
+ this.#emptyStopRetryCount = 0;
4277
4323
 
4278
4324
  await this.#maybeRestoreRetryFallbackPrimary();
4279
4325
 
@@ -6287,6 +6333,99 @@ export class AgentSession {
6287
6333
  return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
6288
6334
  }
6289
6335
 
6336
+ async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
6337
+ if (!this.#isEmptyAssistantStop(assistantMessage)) {
6338
+ this.#emptyStopRetryCount = 0;
6339
+ return false;
6340
+ }
6341
+
6342
+ this.#emptyStopRetryCount++;
6343
+ if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
6344
+ logger.warn("Assistant returned empty stop after retry cap", {
6345
+ attempts: this.#emptyStopRetryCount - 1,
6346
+ model: assistantMessage.model,
6347
+ provider: assistantMessage.provider,
6348
+ });
6349
+ if (this.#retryAttempt > 0) {
6350
+ await this.#emitSessionEvent({
6351
+ type: "auto_retry_end",
6352
+ success: false,
6353
+ attempt: this.#retryAttempt,
6354
+ finalError: "Assistant returned empty stop after retry cap",
6355
+ });
6356
+ this.#retryAttempt = 0;
6357
+ }
6358
+ this.#resolveRetry();
6359
+ return true;
6360
+ }
6361
+
6362
+ this.#removeEmptyStopFromActiveContext(assistantMessage);
6363
+ this.agent.appendMessage({
6364
+ role: "developer",
6365
+ content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
6366
+ attribution: "agent",
6367
+ timestamp: Date.now(),
6368
+ });
6369
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
6370
+ return true;
6371
+ }
6372
+
6373
+ #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
6374
+ if (assistantMessage.stopReason !== "stop") return false;
6375
+ return !assistantMessage.content.some(content => {
6376
+ if (content.type === "text") return content.text.trim().length > 0;
6377
+ if (content.type === "thinking") return content.thinking.trim().length > 0;
6378
+ return content.type === "toolCall";
6379
+ });
6380
+ }
6381
+
6382
+ #emptyStopRetryReminder(): string {
6383
+ return prompt.render(emptyStopRetryTemplate, {
6384
+ retryCount: this.#emptyStopRetryCount,
6385
+ maxRetries: EMPTY_STOP_MAX_RETRIES,
6386
+ });
6387
+ }
6388
+
6389
+ #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
6390
+ const messages = this.agent.state.messages;
6391
+ const lastMessage = messages[messages.length - 1];
6392
+ if (
6393
+ lastMessage?.role === "assistant" &&
6394
+ this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
6395
+ ) {
6396
+ this.agent.replaceMessages(messages.slice(0, -1));
6397
+ }
6398
+
6399
+ const emptyStopEntry = this.sessionManager
6400
+ .getBranch()
6401
+ .slice()
6402
+ .reverse()
6403
+ .find(
6404
+ entry =>
6405
+ entry.type === "message" &&
6406
+ entry.message.role === "assistant" &&
6407
+ this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
6408
+ );
6409
+ if (!emptyStopEntry) {
6410
+ return;
6411
+ }
6412
+ if (emptyStopEntry.parentId === null) {
6413
+ this.sessionManager.resetLeaf();
6414
+ } else {
6415
+ this.sessionManager.branch(emptyStopEntry.parentId);
6416
+ }
6417
+ }
6418
+
6419
+ #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
6420
+ return (
6421
+ left === right ||
6422
+ (left.timestamp === right.timestamp &&
6423
+ left.provider === right.provider &&
6424
+ left.model === right.model &&
6425
+ left.stopReason === right.stopReason)
6426
+ );
6427
+ }
6428
+
6290
6429
  #enforceRewindBeforeYield(): boolean {
6291
6430
  if (!this.#checkpointState || this.#pendingRewindReport) {
6292
6431
  return false;
@@ -10,6 +10,7 @@ import {
10
10
  buildDiscoverableToolSearchIndex,
11
11
  type DiscoverableTool,
12
12
  type DiscoverableToolSearchIndex,
13
+ filterBySource,
13
14
  formatDiscoverableToolServerSummary,
14
15
  searchDiscoverableTools,
15
16
  summarizeDiscoverableTools,
@@ -141,10 +142,15 @@ function isDiscoveryEnabled(session: ToolSession): boolean {
141
142
 
142
143
  export function renderSearchToolBm25Description(discoverableTools: DiscoverableTool[] = []): string {
143
144
  const summary = summarizeDiscoverableTools(discoverableTools);
145
+ const builtinToolNames = filterBySource(discoverableTools, "builtin")
146
+ .map(t => t.name)
147
+ .sort();
144
148
  return prompt.render(searchToolBm25Description, {
145
- discoverableMCPToolCount: summary.toolCount,
149
+ discoverableToolCount: summary.toolCount,
146
150
  discoverableMCPServerSummaries: summary.servers.map(formatDiscoverableToolServerSummary),
147
151
  hasDiscoverableMCPServers: summary.servers.length > 0,
152
+ discoverableBuiltinToolNames: builtinToolNames,
153
+ hasDiscoverableBuiltinTools: builtinToolNames.length > 0,
148
154
  });
149
155
  }
150
156
 
@@ -273,6 +273,12 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
273
273
  readonly loadMode = "discoverable";
274
274
  readonly summary = "Write content to a file (creates or overwrites)";
275
275
 
276
+ /** Stream matchers should see the real file content, not its JSON-escaped argument encoding. */
277
+ matcherDigest(args: unknown): string | undefined {
278
+ const content = (args as Partial<WriteParams>).content;
279
+ return typeof content === "string" ? content : undefined;
280
+ }
281
+
276
282
  readonly #writethrough: WritethroughCallback;
277
283
 
278
284
  constructor(private readonly session: ToolSession) {
package/src/utils/git.ts CHANGED
@@ -189,11 +189,7 @@ function formatCommandFailure(
189
189
  return `git ${args.join(" ")} failed with exit code ${result.exitCode}`;
190
190
  }
191
191
 
192
- async function runCommand(
193
- cwd: string,
194
- args: readonly string[],
195
- options: CommandOptions = {},
196
- ): Promise<GitCommandResult> {
192
+ async function git(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<GitCommandResult> {
197
193
  const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
198
194
  const child = Bun.spawn(["git", ...commandArgs], {
199
195
  cwd,
@@ -248,7 +244,7 @@ async function runChecked(
248
244
  options: CommandOptions = {},
249
245
  ): Promise<GitCommandResult> {
250
246
  ensureAvailable();
251
- const result = await runCommand(cwd, args, options);
247
+ const result = await git(cwd, args, options);
252
248
  if (result.exitCode !== 0) {
253
249
  throw new GitCommandError(args, result);
254
250
  }
@@ -269,7 +265,7 @@ async function tryText(
269
265
  options: CommandOptions = {},
270
266
  ): Promise<string | undefined> {
271
267
  ensureAvailable();
272
- const result = await runCommand(cwd, args, options);
268
+ const result = await git(cwd, args, options);
273
269
  if (result.exitCode !== 0) return undefined;
274
270
  return result.stdout;
275
271
  }
@@ -709,7 +705,7 @@ export const diff = Object.assign(
709
705
  async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
710
706
  const args = buildDiffArgs(options);
711
707
  if (options.allowFailure) {
712
- return (await runCommand(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
708
+ return (await git(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
713
709
  }
714
710
  return runText(cwd, args, { env: options.env, readOnly: true, signal: options.signal });
715
711
  },
@@ -741,7 +737,7 @@ export const diff = Object.assign(
741
737
  if (options.cached) args.push("--cached");
742
738
  args.push("--quiet");
743
739
  if (options.files?.length) args.push("--", ...options.files);
744
- const result = await runCommand(cwd, args, { readOnly: true, signal: options.signal });
740
+ const result = await git(cwd, args, { readOnly: true, signal: options.signal });
745
741
  if (result.exitCode === 0) return false;
746
742
  if (result.exitCode === 1) return true;
747
743
  throw new GitCommandError(args, result);
@@ -757,7 +753,7 @@ export const diff = Object.assign(
757
753
  if (options.binary) args.push("--binary");
758
754
  args.push(base, headRef);
759
755
  if (options.allowFailure) {
760
- return (await runCommand(cwd, args, { readOnly: true, signal: options.signal })).stdout;
756
+ return (await git(cwd, args, { readOnly: true, signal: options.signal })).stdout;
761
757
  }
762
758
  return runText(cwd, args, { readOnly: true, signal: options.signal });
763
759
  },
@@ -789,7 +785,7 @@ export const status = Object.assign(
789
785
  {
790
786
  /** Parsed status counts (staged, unstaged, untracked). */
791
787
  async summary(cwd: string, signal?: AbortSignal): Promise<GitStatusSummary | null> {
792
- const result = await runCommand(cwd, ["status", "--porcelain"], { readOnly: true, signal });
788
+ const result = await git(cwd, ["status", "--porcelain"], { readOnly: true, signal });
793
789
  if (result.exitCode !== 0) return null;
794
790
  return parseStatusPorcelain(result.stdout);
795
791
  },
@@ -950,7 +946,7 @@ export const branch = {
950
946
  async current(cwd: string, signal?: AbortSignal): Promise<string | null> {
951
947
  const headState = await resolveHead(cwd);
952
948
  if (headState?.kind === "ref") return headState.branchName ?? headState.ref;
953
- const result = await runCommand(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal });
949
+ const result = await git(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal });
954
950
  if (result.exitCode !== 0) return null;
955
951
  return result.stdout.trim() || null;
956
952
  },
@@ -966,7 +962,7 @@ export const branch = {
966
962
  }
967
963
  }
968
964
  for (const remoteRef of ["origin/HEAD", "upstream/HEAD"]) {
969
- const result = await runCommand(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal });
965
+ const result = await git(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal });
970
966
  if (result.exitCode !== 0) continue;
971
967
  const branchName = stripRemotePrefix(result.stdout.trim());
972
968
  if (branchName) return branchName;
@@ -995,7 +991,7 @@ export const branch = {
995
991
  name: string,
996
992
  options: { force?: boolean; signal?: AbortSignal } = {},
997
993
  ): Promise<boolean> {
998
- const result = await runCommand(cwd, ["branch", options.force === false ? "-d" : "-D", name], {
994
+ const result = await git(cwd, ["branch", options.force === false ? "-d" : "-D", name], {
999
995
  signal: options.signal,
1000
996
  });
1001
997
  return result.exitCode === 0;
@@ -1038,7 +1034,7 @@ export const remote = {
1038
1034
  * needs to resolve, not paper over.
1039
1035
  */
1040
1036
  async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise<void> {
1041
- const result = await runCommand(cwd, ["remote", "add", name, url], { signal });
1037
+ const result = await git(cwd, ["remote", "add", name, url], { signal });
1042
1038
  if (result.exitCode === 0) return;
1043
1039
  if (REMOTE_ALREADY_EXISTS.test(result.stderr)) {
1044
1040
  const existing = await remote.url(cwd, name, signal);
@@ -1059,7 +1055,7 @@ export const ref = {
1059
1055
  if (refName === "HEAD") return (await head.sha(cwd, signal)) !== null;
1060
1056
  const repository = await resolveRepository(cwd);
1061
1057
  if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName)) !== null;
1062
- const result = await runCommand(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
1058
+ const result = await git(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
1063
1059
  return result.exitCode === 0;
1064
1060
  },
1065
1061
 
@@ -1068,7 +1064,7 @@ export const ref = {
1068
1064
  if (refName === "HEAD") return head.sha(cwd, signal);
1069
1065
  const repository = await resolveRepository(cwd);
1070
1066
  if (repository && refName.startsWith("refs/")) return readRef(repository, refName);
1071
- const result = await runCommand(cwd, ["rev-parse", refName], { readOnly: true, signal });
1067
+ const result = await git(cwd, ["rev-parse", refName], { readOnly: true, signal });
1072
1068
  if (result.exitCode !== 0) return null;
1073
1069
  return result.stdout.trim() || null;
1074
1070
  },
@@ -1150,7 +1146,7 @@ export const worktree = {
1150
1146
  const args = ["worktree", "remove"];
1151
1147
  if (options.force ?? true) args.push("-f");
1152
1148
  args.push(worktreePath);
1153
- const result = await runCommand(cwd, args, { signal: options.signal });
1149
+ const result = await git(cwd, args, { signal: options.signal });
1154
1150
  return result.exitCode === 0;
1155
1151
  },
1156
1152
 
@@ -1186,7 +1182,7 @@ export const patch = {
1186
1182
 
1187
1183
  /** Check if a patch file can be applied cleanly. */
1188
1184
  async canApply(cwd: string, patchPath: string, options: Omit<PatchOptions, "check"> = {}): Promise<boolean> {
1189
- const result = await runCommand(cwd, buildApplyArgs(patchPath, { ...options, check: true }), {
1185
+ const result = await git(cwd, buildApplyArgs(patchPath, { ...options, check: true }), {
1190
1186
  env: options.env,
1191
1187
  readOnly: true,
1192
1188
  signal: options.signal,
@@ -1346,7 +1342,7 @@ export const ls = {
1346
1342
 
1347
1343
  /** List submodule paths (recursive). */
1348
1344
  async submodules(cwd: string, signal?: AbortSignal): Promise<string[]> {
1349
- const output = await runCommand(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
1345
+ const output = await git(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
1350
1346
  readOnly: true,
1351
1347
  signal,
1352
1348
  });
@@ -1381,14 +1377,14 @@ export const head = {
1381
1377
  async sha(cwd: string, signal?: AbortSignal): Promise<string | null> {
1382
1378
  const headState = await head.resolve(cwd);
1383
1379
  if (headState?.commit) return headState.commit;
1384
- const result = await runCommand(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
1380
+ const result = await git(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
1385
1381
  if (result.exitCode !== 0) return null;
1386
1382
  return result.stdout.trim() || null;
1387
1383
  },
1388
1384
 
1389
1385
  /** Abbreviated HEAD commit SHA. */
1390
1386
  async short(cwd: string, length = 7, signal?: AbortSignal): Promise<string | null> {
1391
- const result = await runCommand(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal });
1387
+ const result = await git(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal });
1392
1388
  if (result.exitCode !== 0) return null;
1393
1389
  return result.stdout.trim() || null;
1394
1390
  },
@@ -1403,7 +1399,7 @@ export const repo = {
1403
1399
  async root(cwd: string, signal?: AbortSignal): Promise<string | null> {
1404
1400
  const repository = await resolveRepository(cwd);
1405
1401
  if (repository) return repository.repoRoot;
1406
- const result = await runCommand(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal });
1402
+ const result = await git(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal });
1407
1403
  if (result.exitCode !== 0) return null;
1408
1404
  return result.stdout.trim() || null;
1409
1405
  },