@oh-my-pi/pi-coding-agent 15.2.2 → 15.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sdk.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  Snowflake,
32
32
  } from "@oh-my-pi/pi-utils";
33
33
  import chalk from "chalk";
34
- import { AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
34
+ import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
35
35
  import { createAutoresearchExtension } from "./autoresearch";
36
36
  import { loadCapability } from "./capability";
37
37
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
@@ -101,7 +101,7 @@ import {
101
101
  import { AgentSession } from "./session/agent-session";
102
102
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
103
103
  import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
104
- import { convertToLlm } from "./session/messages";
104
+ import { type CustomMessage, convertToLlm } from "./session/messages";
105
105
  import { SessionManager } from "./session/session-manager";
106
106
  import { closeAllConnections } from "./ssh/connection-manager";
107
107
  import { unmountAll } from "./ssh/sshfs-mount";
@@ -152,6 +152,83 @@ import { EventBus } from "./utils/event-bus";
152
152
  import { buildNamedToolChoice } from "./utils/tool-choice";
153
153
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
154
154
 
155
+ type AsyncResultEntry = {
156
+ jobId: string;
157
+ result: string;
158
+ job: AsyncJob | undefined;
159
+ durationMs: number | undefined;
160
+ };
161
+
162
+ type AsyncResultJobDetails = {
163
+ jobId: string;
164
+ type?: "bash" | "task";
165
+ label?: string;
166
+ durationMs?: number;
167
+ };
168
+
169
+ type AsyncResultDetails = {
170
+ jobs: AsyncResultJobDetails[];
171
+ };
172
+
173
+ type McpNotificationEntry = {
174
+ serverName: string;
175
+ uri: string;
176
+ };
177
+
178
+ function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessage<AsyncResultDetails> | null {
179
+ if (entries.length === 0) return null;
180
+ const jobs = entries.map(entry => ({
181
+ jobId: entry.jobId,
182
+ result: entry.result,
183
+ type: entry.job?.type,
184
+ label: entry.job?.label,
185
+ durationMs: entry.durationMs,
186
+ }));
187
+ const details: AsyncResultDetails = {
188
+ jobs: jobs.map(job => ({
189
+ jobId: job.jobId,
190
+ type: job.type,
191
+ label: job.label,
192
+ durationMs: job.durationMs,
193
+ })),
194
+ };
195
+ return {
196
+ role: "custom",
197
+ customType: "async-result",
198
+ content: prompt.render(asyncResultTemplate, {
199
+ multiple: jobs.length > 1,
200
+ jobs,
201
+ }),
202
+ display: true,
203
+ attribution: "agent",
204
+ details,
205
+ timestamp: Date.now(),
206
+ };
207
+ }
208
+
209
+ function buildMcpNotificationBatchMessage(entries: McpNotificationEntry[]): AgentMessage | null {
210
+ const resources: McpNotificationEntry[] = [];
211
+ const seen = new Set<string>();
212
+ for (const entry of entries) {
213
+ const key = `${entry.serverName}\0${entry.uri}`;
214
+ if (seen.has(key)) continue;
215
+ seen.add(key);
216
+ resources.push(entry);
217
+ }
218
+ if (resources.length === 0) return null;
219
+ const lines = [`[MCP notification] ${resources.length} resource(s) updated:`];
220
+ for (const resource of resources) {
221
+ lines.push(`- server="${resource.serverName}" uri=${resource.uri}`);
222
+ }
223
+ lines.push('Use read(path="mcp://<uri>") to inspect if relevant.');
224
+ return {
225
+ role: "user",
226
+ content: [{ type: "text", text: lines.join("\n") }],
227
+ attribution: "agent",
228
+ timestamp: Date.now(),
229
+ };
230
+ }
231
+
155
232
  // Types
156
233
  export interface CreateAgentSessionOptions {
157
234
  /** Working directory for project-local discovery. Default: getProjectDir() */
@@ -1035,23 +1112,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1035
1112
  const formattedResult = await formatAsyncResultForFollowUp(result);
1036
1113
  if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
1037
1114
 
1038
- const message = prompt.render(asyncResultTemplate, { jobId, result: formattedResult });
1039
1115
  const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
1040
- await session.sendCustomMessage(
1041
- {
1042
- customType: "async-result",
1043
- content: message,
1044
- display: true,
1045
- attribution: "agent",
1046
- details: {
1047
- jobId,
1048
- type: job?.type,
1049
- label: job?.label,
1050
- durationMs,
1051
- },
1052
- },
1053
- { deliverAs: "followUp", triggerTurn: true },
1054
- );
1116
+ session.yieldQueue.enqueue<AsyncResultEntry>("async-result", {
1117
+ jobId,
1118
+ result: formattedResult,
1119
+ job,
1120
+ durationMs,
1121
+ });
1055
1122
  },
1056
1123
  })
1057
1124
  : undefined;
@@ -1902,6 +1969,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1902
1969
  providerSessionId: options.providerSessionId,
1903
1970
  });
1904
1971
  hasSession = true;
1972
+ if (asyncJobManager) {
1973
+ session.yieldQueue.register<AsyncResultEntry>("async-result", {
1974
+ isStale: entry => asyncJobManager.isDeliverySuppressed(entry.jobId),
1975
+ build: buildAsyncResultBatchMessage,
1976
+ });
1977
+ }
1978
+ session.yieldQueue.register<McpNotificationEntry>("mcp-notification", {
1979
+ build: buildMcpNotificationBatchMessage,
1980
+ });
1905
1981
 
1906
1982
  // Attach the live session to the pre-registered ref so peers can route IRC
1907
1983
  // messages here. Refresh sessionFile in case it was unavailable at pre-register
@@ -2036,9 +2112,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2036
2112
  notificationDebounceTimers.delete(key);
2037
2113
  // Re-check: user may have disabled notifications during the debounce window
2038
2114
  if (!settings.get("mcp.notifications")) return;
2039
- void session.followUp(
2040
- `[MCP notification] Server "${serverName}" reports resource \`${uri}\` was updated. Use read(path="mcp://${uri}") to inspect if relevant.`,
2041
- );
2115
+ session.yieldQueue.enqueue<McpNotificationEntry>("mcp-notification", { serverName, uri });
2042
2116
  }, debounceMs),
2043
2117
  );
2044
2118
  });
@@ -205,6 +205,7 @@ import type {
205
205
  } from "./session-manager";
206
206
  import { getLatestCompactionEntry } from "./session-manager";
207
207
  import { ToolChoiceQueue } from "./tool-choice-queue";
208
+ import { YieldQueue } from "./yield-queue";
208
209
 
209
210
  /** Session-specific events that extend the core AgentEvent */
210
211
  export type AgentSessionEvent =
@@ -735,6 +736,7 @@ export class AgentSession {
735
736
  readonly agent: Agent;
736
737
  readonly sessionManager: SessionManager;
737
738
  readonly settings: Settings;
739
+ readonly yieldQueue: YieldQueue;
738
740
 
739
741
  #powerAssertion: MacOSPowerAssertion | undefined;
740
742
 
@@ -1031,6 +1033,24 @@ export class AgentSession {
1031
1033
  };
1032
1034
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1033
1035
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
1036
+ this.yieldQueue = new YieldQueue({
1037
+ isStreaming: () => this.isStreaming,
1038
+ injectStreaming: message => this.agent.followUp(message),
1039
+ injectIdle: async messages => {
1040
+ const first = messages[0];
1041
+ if (!first) return;
1042
+ await this.agent.prompt(messages.length === 1 ? first : messages);
1043
+ },
1044
+ scheduleIdleFlush: run => {
1045
+ this.#schedulePostPromptTask(
1046
+ async () => {
1047
+ await run();
1048
+ },
1049
+ { delayMs: 1 },
1050
+ );
1051
+ },
1052
+ });
1053
+ this.agent.setOnBeforeYield(() => this.yieldQueue.flush("streaming"));
1034
1054
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
1035
1055
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
1036
1056
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -2720,6 +2740,8 @@ export class AgentSession {
2720
2740
  async dispose(): Promise<void> {
2721
2741
  this.#isDisposed = true;
2722
2742
  this.#pendingBackgroundExchanges = [];
2743
+ this.yieldQueue.clear();
2744
+ this.agent.setOnBeforeYield(undefined);
2723
2745
  this.#evalExecutionDisposing = true;
2724
2746
  try {
2725
2747
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
@@ -0,0 +1,155 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import { logger } from "@oh-my-pi/pi-utils";
3
+
4
+ export interface YieldDispatcher<P> {
5
+ /** Drop entries already delivered through another path. Called per-entry at flush time. */
6
+ isStale?(entry: P): boolean;
7
+ /** Produce one batched AgentMessage from non-stale entries. Return null to skip. */
8
+ build(survivors: P[]): AgentMessage | null;
9
+ }
10
+
11
+ export interface YieldQueueOptions {
12
+ isStreaming: () => boolean;
13
+ injectStreaming(msg: AgentMessage): void;
14
+ injectIdle(messages: AgentMessage[]): Promise<void>;
15
+ scheduleIdleFlush(run: () => Promise<void>): void;
16
+ }
17
+
18
+ type YieldFlushMode = "streaming" | "idle";
19
+
20
+ interface StoredDispatcher {
21
+ isStale?: (entry: unknown) => boolean;
22
+ build: (survivors: unknown[]) => AgentMessage | null;
23
+ }
24
+
25
+ function formatError(error: unknown): string {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+
29
+ export class YieldQueue {
30
+ readonly #options: YieldQueueOptions;
31
+ readonly #dispatchers = new Map<string, StoredDispatcher>();
32
+ readonly #entries = new Map<string, unknown[]>();
33
+ #idleFlushPending = false;
34
+
35
+ constructor(options: YieldQueueOptions) {
36
+ this.#options = options;
37
+ }
38
+
39
+ register<P>(kind: string, dispatcher: YieldDispatcher<P>): () => void {
40
+ const stored: StoredDispatcher = {
41
+ ...(dispatcher.isStale ? { isStale: entry => dispatcher.isStale?.(entry as P) ?? false } : {}),
42
+ build: survivors => dispatcher.build(survivors as P[]),
43
+ };
44
+ this.#dispatchers.set(kind, stored);
45
+ return () => {
46
+ if (this.#dispatchers.get(kind) !== stored) return;
47
+ this.#dispatchers.delete(kind);
48
+ this.#entries.delete(kind);
49
+ };
50
+ }
51
+
52
+ enqueue<P>(kind: string, entry: P): void {
53
+ if (!this.#dispatchers.has(kind)) {
54
+ logger.warn("Yield queue entry ignored for unregistered kind", { kind });
55
+ return;
56
+ }
57
+ let entries = this.#entries.get(kind);
58
+ if (!entries) {
59
+ entries = [];
60
+ this.#entries.set(kind, entries);
61
+ }
62
+ entries.push(entry);
63
+ if (!this.#options.isStreaming()) {
64
+ this.#scheduleIdleFlush();
65
+ }
66
+ }
67
+
68
+ has(kind?: string): boolean {
69
+ if (kind !== undefined) return (this.#entries.get(kind)?.length ?? 0) > 0;
70
+ for (const entries of this.#entries.values()) {
71
+ if (entries.length > 0) return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ async flush(mode: YieldFlushMode): Promise<void> {
77
+ if (mode === "idle") {
78
+ this.#idleFlushPending = false;
79
+ }
80
+ const idleMessages: AgentMessage[] = [];
81
+ for (const [kind, dispatcher] of this.#dispatchers) {
82
+ const entries = this.#drain(kind);
83
+ if (entries.length === 0) continue;
84
+ const message = this.#build(kind, dispatcher, entries);
85
+ if (!message) continue;
86
+ if (mode === "streaming") {
87
+ try {
88
+ this.#options.injectStreaming(message);
89
+ } catch (error) {
90
+ logger.warn("Yield queue streaming dispatch failed", { kind, error: formatError(error) });
91
+ }
92
+ } else {
93
+ idleMessages.push(message);
94
+ }
95
+ }
96
+ if (mode === "idle" && idleMessages.length > 0) {
97
+ try {
98
+ await this.#options.injectIdle(idleMessages);
99
+ } catch (error) {
100
+ logger.warn("Yield queue idle dispatch failed", { error: formatError(error) });
101
+ }
102
+ }
103
+ }
104
+
105
+ clear(): void {
106
+ this.#entries.clear();
107
+ this.#idleFlushPending = false;
108
+ }
109
+
110
+ #scheduleIdleFlush(): void {
111
+ if (this.#idleFlushPending) return;
112
+ this.#idleFlushPending = true;
113
+ try {
114
+ this.#options.scheduleIdleFlush(async () => {
115
+ this.#idleFlushPending = false;
116
+ if (this.#options.isStreaming()) return;
117
+ await this.flush("idle");
118
+ });
119
+ } catch (error) {
120
+ this.#idleFlushPending = false;
121
+ logger.warn("Yield queue idle flush scheduling failed", { error: formatError(error) });
122
+ }
123
+ }
124
+
125
+ #drain(kind: string): unknown[] {
126
+ const entries = this.#entries.get(kind);
127
+ if (!entries || entries.length === 0) return [];
128
+ this.#entries.delete(kind);
129
+ return entries;
130
+ }
131
+
132
+ #build(kind: string, dispatcher: StoredDispatcher, entries: unknown[]): AgentMessage | null {
133
+ const survivors: unknown[] = [];
134
+ for (const entry of entries) {
135
+ if (dispatcher.isStale) {
136
+ let stale: boolean;
137
+ try {
138
+ stale = dispatcher.isStale(entry);
139
+ } catch (error) {
140
+ logger.warn("Yield queue stale check failed", { kind, error: formatError(error) });
141
+ continue;
142
+ }
143
+ if (stale) continue;
144
+ }
145
+ survivors.push(entry);
146
+ }
147
+ if (survivors.length === 0) return null;
148
+ try {
149
+ return dispatcher.build(survivors);
150
+ } catch (error) {
151
+ logger.warn("Yield queue build failed", { kind, error: formatError(error) });
152
+ return null;
153
+ }
154
+ }
155
+ }
@@ -13,7 +13,7 @@ export function formatDuration(ms: number): string {
13
13
  return `${days}d`;
14
14
  }
15
15
 
16
- type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
16
+ type ProgressBarTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
17
17
 
18
18
  const unstyledProgressBarTheme: ProgressBarTheme = {
19
19
  fg(_color, text) {
@@ -22,6 +22,9 @@ const unstyledProgressBarTheme: ProgressBarTheme = {
22
22
  bold(text) {
23
23
  return text;
24
24
  },
25
+ getFgAnsi() {
26
+ return "";
27
+ },
25
28
  };
26
29
 
27
30
  function resolveProgressBarTheme(uiTheme: ProgressBarTheme | undefined): ProgressBarTheme {
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import * as natives from "@oh-my-pi/pi-natives";
6
- import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
6
+ import { getWorktreeDir, hashPath, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import * as git from "../utils/git";
8
8
 
9
9
  const { IsoBackendKind } = natives;
@@ -26,10 +26,6 @@ export interface WorktreeBaseline {
26
26
  nested: Array<{ relativePath: string; baseline: RepoBaseline }>;
27
27
  }
28
28
 
29
- export function getEncodedProjectName(cwd: string): string {
30
- return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
31
- }
32
-
33
29
  export async function getRepoRoot(cwd: string): Promise<string> {
34
30
  const repoRoot = await git.repo.root(cwd);
35
31
  if (!repoRoot) {
@@ -316,8 +312,7 @@ export async function ensureIsolation(
316
312
  preferred?: IsoBackendKind,
317
313
  ): Promise<IsolationHandle> {
318
314
  const repoRoot = await getRepoRoot(baseCwd);
319
- const encodedProject = getEncodedProjectName(repoRoot);
320
- const baseDir = getWorktreeDir(encodedProject, id);
315
+ const baseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
321
316
  const mergedDir = path.join(baseDir, "merged");
322
317
 
323
318
  const resolution = natives.isoResolve(preferred ?? null);
package/src/tools/gh.ts CHANGED
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { scheduler } from "node:timers/promises";
5
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
6
 
7
- import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
+ import { getWorktreeDir, hashPath, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
9
  import type { Settings } from "../config/settings";
10
10
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
@@ -859,18 +859,8 @@ function sanitizeRemoteName(value: string): string {
859
859
  return sanitized.length > 0 ? `fork-${sanitized}` : "fork";
860
860
  }
861
861
 
862
- /**
863
- * Encode an absolute repository path into a single filesystem-safe segment.
864
- * Mirrors the legacy session-dir encoding used elsewhere in the project: drop
865
- * the leading separator, then collapse `/`, `\\`, and `:` to `-`. The result
866
- * is not strictly injective for pathological inputs (e.g. `/a/b` vs `/a-b`)
867
- * but matches the rest of the codebase and stays human-readable.
868
- */
869
- function encodeRepoPathForFilesystem(repoPath: string): string {
870
- const resolved = path.resolve(repoPath);
871
- const encoded = resolved.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
872
- return encoded || "root";
873
- }
862
+ /** Maximum disambiguation suffixes we try before giving up on a worktree path. */
863
+ const WORKTREE_PATH_MAX_SUFFIX = 100;
874
864
 
875
865
  function toLocalBranchRef(value: string): string {
876
866
  return `refs/heads/${value}`;
@@ -912,25 +902,38 @@ async function requireCurrentGitHead(cwd: string, signal?: AbortSignal): Promise
912
902
  return headSha;
913
903
  }
914
904
 
915
- async function ensureGitWorktreePathAvailable(
916
- worktreePath: string,
905
+ /**
906
+ * Resolve a worktree path that is free of conflicts.
907
+ *
908
+ * Given a `basePath`, return either `basePath` itself or `${basePath}-2`,
909
+ * `${basePath}-3`, … up to {@link WORKTREE_PATH_MAX_SUFFIX} — whichever is the
910
+ * first variant that is **not** registered with git as another worktree and
911
+ * **not** present on disk. The numeric tail salvages two rare cases that
912
+ * would otherwise abort a checkout: stale leftover dirs from an interrupted
913
+ * `git worktree add`, and the (vanishingly unlikely) `hashPath` collision
914
+ * between two repos that happen to produce the same 7-hex digest.
915
+ */
916
+ async function resolveAvailableWorktreePath(
917
+ basePath: string,
917
918
  existingWorktrees: git.GitWorktreeEntry[],
918
- ): Promise<void> {
919
- const normalizedTarget = path.resolve(worktreePath);
920
- const conflictingWorktree = existingWorktrees.find(entry => path.resolve(entry.path) === normalizedTarget);
921
- if (conflictingWorktree) {
922
- throw new ToolError(`worktree path is already registered: ${conflictingWorktree.path}`);
923
- }
924
-
925
- try {
926
- await fs.stat(normalizedTarget);
927
- throw new ToolError(`worktree path already exists: ${normalizedTarget}`);
928
- } catch (error) {
929
- if (isEnoent(error)) {
930
- return;
919
+ ): Promise<string> {
920
+ const registered = new Set(existingWorktrees.map(entry => path.resolve(entry.path)));
921
+ for (let attempt = 0; attempt < WORKTREE_PATH_MAX_SUFFIX; attempt += 1) {
922
+ const candidate = attempt === 0 ? basePath : `${basePath}-${attempt + 1}`;
923
+ const normalized = path.resolve(candidate);
924
+ if (registered.has(normalized)) continue;
925
+ try {
926
+ await fs.stat(normalized);
927
+ } catch (error) {
928
+ if (isEnoent(error)) {
929
+ return candidate;
930
+ }
931
+ throw error;
931
932
  }
932
- throw error;
933
933
  }
934
+ throw new ToolError(
935
+ `could not find an unused worktree path under ${basePath} (tried ${WORKTREE_PATH_MAX_SUFFIX} suffixes)`,
936
+ );
934
937
  }
935
938
 
936
939
  function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewData, "url" | "sshUrl">): string {
@@ -2939,7 +2942,7 @@ async function checkoutPullRequest(
2939
2942
  const repoRoot = await requireGitRepoRoot(session.cwd, signal);
2940
2943
  const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
2941
2944
  const localBranch = `pr-${prNumber}`;
2942
- const worktreePath = path.join(getWorktreesDir(), encodeRepoPathForFilesystem(primaryRepoRoot), localBranch);
2945
+ const worktreePath = getWorktreeDir(`${prNumber}-${hashPath(primaryRepoRoot)}`);
2943
2946
 
2944
2947
  // Every git mutation against `repoRoot` from here on must run under the
2945
2948
  // per-repo lock. Worktrees of the same primary repo share `.git/config`,
@@ -3003,9 +3006,9 @@ async function checkoutPullRequest(
3003
3006
  signal,
3004
3007
  );
3005
3008
 
3006
- const finalWorktreePath = existingWorktree?.path ?? worktreePath;
3009
+ let finalWorktreePath = existingWorktree?.path ?? worktreePath;
3007
3010
  if (!existingWorktree) {
3008
- await ensureGitWorktreePathAvailable(finalWorktreePath, existingWorktrees);
3011
+ finalWorktreePath = await resolveAvailableWorktreePath(worktreePath, existingWorktrees);
3009
3012
  await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
3010
3013
  await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
3011
3014
  }
package/src/utils/git.ts CHANGED
@@ -1157,6 +1157,10 @@ export const worktree = {
1157
1157
  async list(cwd: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]> {
1158
1158
  return parseWorktreeList(await runText(cwd, ["worktree", "list", "--porcelain"], { readOnly: true, signal }));
1159
1159
  },
1160
+
1161
+ async prune(cwd: string, signal?: AbortSignal): Promise<void> {
1162
+ await runEffect(cwd, ["worktree", "prune"], { signal });
1163
+ },
1160
1164
  };
1161
1165
 
1162
1166
  // ════════════════════════════════════════════════════════════════════════════