@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/CHANGELOG.md +28 -1
- package/dist/types/cli/worktree-cli.d.ts +26 -0
- package/dist/types/commands/worktree.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +23 -0
- package/dist/types/modes/theme/shimmer.d.ts +15 -7
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/yield-queue.d.ts +24 -0
- package/dist/types/slash-commands/helpers/format.d.ts +1 -1
- package/dist/types/task/worktree.d.ts +0 -1
- package/dist/types/utils/git.d.ts +1 -0
- package/package.json +7 -7
- package/src/autoresearch/storage.ts +14 -2
- package/src/cli/worktree-cli.ts +291 -0
- package/src/cli.ts +1 -0
- package/src/commands/worktree.ts +56 -0
- package/src/config/settings-schema.ts +16 -0
- package/src/modes/components/mcp-add-wizard.ts +4 -3
- package/src/modes/components/settings-selector.ts +23 -10
- package/src/modes/components/welcome.ts +77 -35
- package/src/modes/controllers/mcp-command-controller.ts +4 -3
- package/src/modes/theme/shimmer.ts +161 -30
- package/src/modes/utils/ui-helpers.ts +31 -13
- package/src/prompts/tools/async-result.md +5 -2
- package/src/sdk.ts +95 -21
- package/src/session/agent-session.ts +22 -0
- package/src/session/yield-queue.ts +155 -0
- package/src/slash-commands/helpers/format.ts +4 -1
- package/src/task/worktree.ts +2 -7
- package/src/tools/gh.ts +35 -32
- package/src/utils/git.ts +4 -0
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
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 {
|
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
916
|
-
|
|
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<
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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 =
|
|
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
|
-
|
|
3009
|
+
let finalWorktreePath = existingWorktree?.path ?? worktreePath;
|
|
3007
3010
|
if (!existingWorktree) {
|
|
3008
|
-
await
|
|
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
|
// ════════════════════════════════════════════════════════════════════════════
|