wave-code 0.19.8 → 0.19.9
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/dist/cli.js +1 -1
- package/dist/components/TaskList.js +28 -12
- package/dist/index.js +1 -1
- package/dist/print-cli.js +2 -2
- package/dist/stdio/agentBridge.d.ts +6 -0
- package/dist/stdio/agentBridge.js +118 -1
- package/dist/stdio/protocol.d.ts +1 -1
- package/dist/utils/worktree.d.ts +4 -2
- package/dist/utils/worktree.js +29 -24
- package/package.json +2 -2
- package/src/cli.tsx +1 -1
- package/src/components/TaskList.tsx +30 -12
- package/src/index.ts +1 -1
- package/src/print-cli.ts +2 -2
- package/src/stdio/agentBridge.ts +186 -0
- package/src/stdio/protocol.ts +6 -1
- package/src/utils/worktree.ts +49 -33
package/dist/cli.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function startCli(options) {
|
|
|
23
23
|
// Cleanup worktree if requested
|
|
24
24
|
if (shouldRemoveWorktree && worktreeSession) {
|
|
25
25
|
process.chdir(worktreeSession.repoRoot);
|
|
26
|
-
removeWorktree(worktreeSession);
|
|
26
|
+
await removeWorktree(worktreeSession);
|
|
27
27
|
}
|
|
28
28
|
process.exit(0);
|
|
29
29
|
}
|
|
@@ -80,12 +80,9 @@ export const TaskList = () => {
|
|
|
80
80
|
const completionTimestampsRef = React.useRef(new Map());
|
|
81
81
|
const previousCompletedIdsRef = React.useRef(null);
|
|
82
82
|
const autoHideTimerRef = React.useRef(null);
|
|
83
|
-
const [autoHidden, setAutoHidden] = React.useState(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const active = tasks.filter((t) => t.status !== "deleted");
|
|
87
|
-
return active.length > 0 && active.every((t) => t.status === "completed");
|
|
88
|
-
});
|
|
83
|
+
const [autoHidden, setAutoHidden] = React.useState(false);
|
|
84
|
+
// 是否在当前展示期间观察到过未完成任务;用于区分“任务在眼前完成”与“加载时已全部完成”
|
|
85
|
+
const hadIncompleteRef = React.useRef(false);
|
|
89
86
|
const [, forceUpdate] = React.useState(0);
|
|
90
87
|
const now = Date.now();
|
|
91
88
|
const activeTasks = tasks.filter((t) => t.status !== "deleted");
|
|
@@ -125,23 +122,42 @@ export const TaskList = () => {
|
|
|
125
122
|
// Auto-hide logic: when all active tasks are completed
|
|
126
123
|
const allCompleted = activeTasks.length > 0 &&
|
|
127
124
|
activeTasks.every((t) => t.status === "completed");
|
|
125
|
+
// 观察到任务从“存在未完成”变为“全部完成”时保留 5 秒再隐藏;
|
|
126
|
+
// 加载或恢复到已全部完成的会话时立即隐藏,避免先闪现再消失
|
|
128
127
|
React.useEffect(() => {
|
|
129
|
-
if (
|
|
128
|
+
if (activeTasks.length === 0) {
|
|
129
|
+
hadIncompleteRef.current = false;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!allCompleted) {
|
|
133
|
+
hadIncompleteRef.current = true;
|
|
134
|
+
if (autoHidden) {
|
|
135
|
+
setAutoHidden(false);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!hadIncompleteRef.current) {
|
|
140
|
+
if (!autoHidden) {
|
|
141
|
+
setAutoHidden(true);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!autoHidden) {
|
|
130
146
|
autoHideTimerRef.current = setTimeout(() => {
|
|
131
147
|
setAutoHidden(true);
|
|
132
148
|
}, AUTO_HIDE_DELAY_MS);
|
|
133
149
|
}
|
|
134
|
-
if (!allCompleted && autoHidden) {
|
|
135
|
-
setAutoHidden(false);
|
|
136
|
-
}
|
|
137
150
|
return () => {
|
|
138
151
|
if (autoHideTimerRef.current) {
|
|
139
152
|
clearTimeout(autoHideTimerRef.current);
|
|
140
153
|
autoHideTimerRef.current = null;
|
|
141
154
|
}
|
|
142
155
|
};
|
|
143
|
-
}, [allCompleted, autoHidden]);
|
|
144
|
-
if (tasks.length === 0 ||
|
|
156
|
+
}, [allCompleted, autoHidden, activeTasks.length]);
|
|
157
|
+
if (tasks.length === 0 ||
|
|
158
|
+
!isTaskListVisible ||
|
|
159
|
+
autoHidden ||
|
|
160
|
+
(allCompleted && !hadIncompleteRef.current)) {
|
|
145
161
|
return null;
|
|
146
162
|
}
|
|
147
163
|
const displayLimit = getDisplayLimit(stdout?.rows);
|
package/dist/index.js
CHANGED
|
@@ -237,7 +237,7 @@ export async function main() {
|
|
|
237
237
|
name = generateRandomName();
|
|
238
238
|
}
|
|
239
239
|
const baseRef = loadMergedWaveConfig(originalCwd)?.worktree?.baseRef;
|
|
240
|
-
worktreeSession = createWorktree(name, originalCwd, { baseRef });
|
|
240
|
+
worktreeSession = await createWorktree(name, originalCwd, { baseRef });
|
|
241
241
|
// Note: the full worktree session (originalCwd etc.) is injected into the
|
|
242
242
|
// agent's DI container after the agent is created in useChat.tsx. This keeps
|
|
243
243
|
// worktree state per-session instead of process-global.
|
package/dist/print-cli.js
CHANGED
|
@@ -138,7 +138,7 @@ export async function startPrintCli(options) {
|
|
|
138
138
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
139
139
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
140
140
|
if (!hasChanges && !hasCommits) {
|
|
141
|
-
removeWorktree(worktreeSession);
|
|
141
|
+
await removeWorktree(worktreeSession);
|
|
142
142
|
}
|
|
143
143
|
else {
|
|
144
144
|
process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
|
|
@@ -170,7 +170,7 @@ export async function startPrintCli(options) {
|
|
|
170
170
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
171
171
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
172
172
|
if (!hasChanges && !hasCommits) {
|
|
173
|
-
removeWorktree(worktreeSession);
|
|
173
|
+
await removeWorktree(worktreeSession);
|
|
174
174
|
}
|
|
175
175
|
else {
|
|
176
176
|
process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
|
|
@@ -33,6 +33,9 @@ export declare class AgentBridge {
|
|
|
33
33
|
private destroy;
|
|
34
34
|
private restoreSession;
|
|
35
35
|
private listSessions;
|
|
36
|
+
private listGitBranches;
|
|
37
|
+
private createWorktreeSession;
|
|
38
|
+
private removeWorktreeSession;
|
|
36
39
|
private getSessionInfo;
|
|
37
40
|
private updateConfig;
|
|
38
41
|
private sendMessage;
|
|
@@ -40,6 +43,7 @@ export declare class AgentBridge {
|
|
|
40
43
|
private abortMessage;
|
|
41
44
|
private clearMessages;
|
|
42
45
|
private rewindToMessage;
|
|
46
|
+
private listRewindCheckpoints;
|
|
43
47
|
private deleteQueuedMessage;
|
|
44
48
|
private updateQueuedMessage;
|
|
45
49
|
private deleteQueuedMessageById;
|
|
@@ -69,6 +73,8 @@ export declare class AgentBridge {
|
|
|
69
73
|
private uninstallPlugin;
|
|
70
74
|
private enablePlugin;
|
|
71
75
|
private disablePlugin;
|
|
76
|
+
private getProjectSettings;
|
|
77
|
+
private setBuiltinPluginEnabled;
|
|
72
78
|
private updatePlugin;
|
|
73
79
|
private listMarketplaces;
|
|
74
80
|
private addMarketplace;
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
* - Implement the canUseTool permission flow over the stdio protocol
|
|
15
15
|
* - Handle config updates by destroying and recreating the Agent
|
|
16
16
|
*/
|
|
17
|
-
import { Agent, listSessions, searchFiles, PromptHistoryManager, AuthService, PluginCore, } from "wave-agent-sdk";
|
|
17
|
+
import { Agent, listSessions, searchFiles, generateRandomName, getDefaultRemoteBranch, getMessageContent, PromptHistoryManager, AuthService, PluginCore, } from "wave-agent-sdk";
|
|
18
18
|
import { INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR, METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND, } from "./protocol.js";
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { createWorktree, removeWorktree } from "../utils/worktree.js";
|
|
19
21
|
import { logger } from "../utils/logger.js";
|
|
20
22
|
export class AgentBridge {
|
|
21
23
|
constructor(options) {
|
|
@@ -52,6 +54,8 @@ export class AgentBridge {
|
|
|
52
54
|
return this.clearMessages(sessionId);
|
|
53
55
|
case "rewindToMessage":
|
|
54
56
|
return this.rewindToMessage(p.messageId, sessionId);
|
|
57
|
+
case "listRewindCheckpoints":
|
|
58
|
+
return this.listRewindCheckpoints(sessionId);
|
|
55
59
|
case "deleteQueuedMessage":
|
|
56
60
|
return this.deleteQueuedMessage(p.index, sessionId);
|
|
57
61
|
case "updateQueuedMessage":
|
|
@@ -102,6 +106,10 @@ export class AgentBridge {
|
|
|
102
106
|
return this.enablePlugin(p.pluginId, p.scope, p.workdir, sessionId);
|
|
103
107
|
case "disablePlugin":
|
|
104
108
|
return this.disablePlugin(p.pluginId, p.scope, p.workdir, sessionId);
|
|
109
|
+
case "getProjectSettings":
|
|
110
|
+
return this.getProjectSettings(p.workdir, sessionId);
|
|
111
|
+
case "setBuiltinPluginEnabled":
|
|
112
|
+
return this.setBuiltinPluginEnabled(p.pluginId, p.enabled, p.scope, p.workdir, sessionId);
|
|
105
113
|
case "updatePlugin":
|
|
106
114
|
return this.updatePlugin(p.pluginId, p.workdir, sessionId);
|
|
107
115
|
case "listMarketplaces":
|
|
@@ -123,6 +131,13 @@ export class AgentBridge {
|
|
|
123
131
|
return this.getWorkflowRuns(sessionId);
|
|
124
132
|
case "stopWorkflowRun":
|
|
125
133
|
return this.stopWorkflowRun(p.runId, sessionId);
|
|
134
|
+
// ── Git / worktree (global — no session required) ──
|
|
135
|
+
case "listGitBranches":
|
|
136
|
+
return this.listGitBranches(p.workdir);
|
|
137
|
+
case "createWorktree":
|
|
138
|
+
return this.createWorktreeSession(p);
|
|
139
|
+
case "removeWorktree":
|
|
140
|
+
return this.removeWorktreeSession(p);
|
|
126
141
|
default:
|
|
127
142
|
throw new RpcError(PROTOCOL_METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
128
143
|
}
|
|
@@ -159,6 +174,8 @@ export class AgentBridge {
|
|
|
159
174
|
disallowedTools: params.disallowedTools,
|
|
160
175
|
plugins: params.pluginDirs?.map((path) => ({ type: "local", path })),
|
|
161
176
|
mcpServers: params.mcpServers,
|
|
177
|
+
worktreeName: params.worktreeName,
|
|
178
|
+
isNewWorktree: params.isNewWorktree,
|
|
162
179
|
canUseTool: (context) => this.canUseTool(context, ctx),
|
|
163
180
|
};
|
|
164
181
|
const agent = await Agent.create(options);
|
|
@@ -194,6 +211,76 @@ export class AgentBridge {
|
|
|
194
211
|
const sessions = await listSessions(workdir || this.getSessionWorkdir(sessionId) || process.cwd());
|
|
195
212
|
return { sessions };
|
|
196
213
|
}
|
|
214
|
+
// ── Git / worktree ────────────────────────────────────────────
|
|
215
|
+
listGitBranches(workdir) {
|
|
216
|
+
if (!workdir) {
|
|
217
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
218
|
+
}
|
|
219
|
+
const gitOpts = {
|
|
220
|
+
cwd: workdir,
|
|
221
|
+
encoding: "utf8",
|
|
222
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
223
|
+
};
|
|
224
|
+
let branchesRaw;
|
|
225
|
+
try {
|
|
226
|
+
branchesRaw = execFileSync("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads"], gitOpts).trim();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, `Not a git repository (or git unavailable): ${workdir}`);
|
|
230
|
+
}
|
|
231
|
+
const branches = branchesRaw
|
|
232
|
+
? branchesRaw
|
|
233
|
+
.split("\n")
|
|
234
|
+
.map((b) => b.trim())
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
: [];
|
|
237
|
+
let current = null;
|
|
238
|
+
try {
|
|
239
|
+
const head = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], gitOpts).trim();
|
|
240
|
+
// Detached HEAD prints "HEAD" — treat as no current branch.
|
|
241
|
+
current = head && head !== "HEAD" ? head : null;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
current = null;
|
|
245
|
+
}
|
|
246
|
+
return { branches, current };
|
|
247
|
+
}
|
|
248
|
+
async createWorktreeSession(params) {
|
|
249
|
+
if (!params.workdir) {
|
|
250
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
251
|
+
}
|
|
252
|
+
const name = params.name?.trim() || generateRandomName();
|
|
253
|
+
try {
|
|
254
|
+
const session = await createWorktree(name, params.workdir, {
|
|
255
|
+
baseBranch: params.baseBranch,
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
name: session.name,
|
|
259
|
+
path: session.path,
|
|
260
|
+
branch: session.branch,
|
|
261
|
+
repoRoot: session.repoRoot,
|
|
262
|
+
baseBranch: params.baseBranch ?? getDefaultRemoteBranch(params.workdir),
|
|
263
|
+
isNew: session.isNew,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, e.message);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async removeWorktreeSession(params) {
|
|
271
|
+
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
272
|
+
// branches only log, never throw.
|
|
273
|
+
await removeWorktree({
|
|
274
|
+
name: "",
|
|
275
|
+
path: params.path,
|
|
276
|
+
branch: params.branch,
|
|
277
|
+
repoRoot: params.repoRoot,
|
|
278
|
+
hasUncommittedChanges: false,
|
|
279
|
+
hasNewCommits: false,
|
|
280
|
+
isNew: false,
|
|
281
|
+
});
|
|
282
|
+
return { ok: true };
|
|
283
|
+
}
|
|
197
284
|
getSessionInfo(sessionId) {
|
|
198
285
|
const entry = this.requireSession(sessionId);
|
|
199
286
|
return {
|
|
@@ -233,6 +320,9 @@ export class AgentBridge {
|
|
|
233
320
|
path: p,
|
|
234
321
|
})),
|
|
235
322
|
mcpServers: entry.storedConfig.mcpServers,
|
|
323
|
+
// Keep worktree context (permission safety) across recreation, but never
|
|
324
|
+
// re-fire WorktreeCreate — the hook ran at initial creation.
|
|
325
|
+
worktreeName: entry.storedConfig.worktreeName,
|
|
236
326
|
canUseTool: (context) => this.canUseTool(context, ctx),
|
|
237
327
|
};
|
|
238
328
|
const agent = await Agent.create(options);
|
|
@@ -287,6 +377,17 @@ export class AgentBridge {
|
|
|
287
377
|
await entry.agent.truncateHistory(index);
|
|
288
378
|
return { inputContent: textBlock?.content || "" };
|
|
289
379
|
}
|
|
380
|
+
async listRewindCheckpoints(sessionId) {
|
|
381
|
+
const entry = this.requireSession(sessionId);
|
|
382
|
+
const { messages } = await entry.agent.getFullMessageThread();
|
|
383
|
+
const checkpoints = messages
|
|
384
|
+
.filter((m) => m.role === "user" && !m.isMeta && m.id)
|
|
385
|
+
.map((m) => ({
|
|
386
|
+
id: m.id,
|
|
387
|
+
content: getMessageContent(m).replace(/\s+/g, " ").trim(),
|
|
388
|
+
}));
|
|
389
|
+
return { checkpoints };
|
|
390
|
+
}
|
|
290
391
|
deleteQueuedMessage(index, sessionId) {
|
|
291
392
|
const entry = this.requireSession(sessionId);
|
|
292
393
|
entry.agent.removeQueuedMessage(index);
|
|
@@ -467,6 +568,22 @@ export class AgentBridge {
|
|
|
467
568
|
async disablePlugin(pluginId, scope, workdir, sessionId) {
|
|
468
569
|
return this.getPluginCore(workdir, sessionId).disablePlugin(pluginId, scope);
|
|
469
570
|
}
|
|
571
|
+
async getProjectSettings(workdir, sessionId) {
|
|
572
|
+
return {
|
|
573
|
+
enabledPlugins: this.getPluginCore(workdir, sessionId).getMergedEnabledPlugins(),
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
async setBuiltinPluginEnabled(pluginId, enabled, scope, workdir, sessionId) {
|
|
577
|
+
const core = this.getPluginCore(workdir, sessionId);
|
|
578
|
+
const targetScope = scope ?? "project";
|
|
579
|
+
if (enabled) {
|
|
580
|
+
await core.enablePlugin(pluginId, targetScope);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
await core.disablePlugin(pluginId, targetScope);
|
|
584
|
+
}
|
|
585
|
+
return { enabledPlugins: core.getMergedEnabledPlugins() };
|
|
586
|
+
}
|
|
470
587
|
async updatePlugin(pluginId, workdir, sessionId) {
|
|
471
588
|
return this.getPluginCore(workdir, sessionId).updatePlugin(pluginId);
|
|
472
589
|
}
|
package/dist/stdio/protocol.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export declare const INVALID_REQUEST = -32600;
|
|
|
34
34
|
export declare const METHOD_NOT_FOUND = -32601;
|
|
35
35
|
export declare const INVALID_PARAMS = -32602;
|
|
36
36
|
export declare const INTERNAL_ERROR = -32603;
|
|
37
|
-
export type RequestMethod = "initialize" | "destroy" | "restoreSession" | "listSessions" | "getSessionInfo" | "sendMessage" | "bang" | "abortMessage" | "clearMessages" | "rewindToMessage" | "deleteQueuedMessage" | "updateQueuedMessage" | "deleteQueuedMessageById" | "getMessages" | "getFullMessageThread" | "setPermissionMode" | "getPermissionMode" | "getMcpServers" | "connectMcpServer" | "disconnectMcpServer" | "getSlashCommands" | "searchFiles" | "getPromptHistory" | "searchPromptHistory" | "updateConfig" | "getAuthStatus" | "login" | "logout" | "listPlugins" | "installPlugin" | "uninstallPlugin" | "enablePlugin" | "disablePlugin" | "updatePlugin" | "listMarketplaces" | "addMarketplace" | "removeMarketplace" | "updateMarketplace" | "compact" | "getBackgroundTaskOutput" | "stopBackgroundTask" | "getWorkflowRuns" | "stopWorkflowRun";
|
|
37
|
+
export type RequestMethod = "initialize" | "destroy" | "restoreSession" | "listSessions" | "getSessionInfo" | "sendMessage" | "bang" | "abortMessage" | "clearMessages" | "rewindToMessage" | "listRewindCheckpoints" | "deleteQueuedMessage" | "updateQueuedMessage" | "deleteQueuedMessageById" | "getMessages" | "getFullMessageThread" | "setPermissionMode" | "getPermissionMode" | "getMcpServers" | "connectMcpServer" | "disconnectMcpServer" | "getSlashCommands" | "searchFiles" | "getPromptHistory" | "searchPromptHistory" | "updateConfig" | "getAuthStatus" | "login" | "logout" | "listPlugins" | "installPlugin" | "uninstallPlugin" | "enablePlugin" | "disablePlugin" | "updatePlugin" | "listMarketplaces" | "addMarketplace" | "removeMarketplace" | "updateMarketplace" | "compact" | "getBackgroundTaskOutput" | "stopBackgroundTask" | "getWorkflowRuns" | "stopWorkflowRun" | "listGitBranches" | "createWorktree" | "removeWorktree";
|
|
38
38
|
export type ClientNotificationMethod = "permissionResponse";
|
|
39
39
|
export type ServerNotificationMethod = "messagesChange" | "userMessageAdded" | "assistantMessageAdded" | "assistantContentUpdated" | "assistantReasoningUpdated" | "toolBlockUpdated" | "errorBlockAdded" | "loadingChange" | "commandRunningChange" | "queuedMessagesChange" | "tasksChange" | "sessionIdChange" | "permissionModeChange" | "mcpServersChange" | "workdirChange" | "bangMessageAdded" | "bangMessageUpdated" | "bangMessageCompleted" | "notificationMessageAdded" | "permissionRequest" | "authUrl" | "compactBlockAdded" | "compactionStateChange" | "backgroundTasksChange";
|
|
40
40
|
export declare function isRequest(msg: unknown): msg is JsonRpcRequest;
|
package/dist/utils/worktree.d.ts
CHANGED
|
@@ -13,13 +13,15 @@ export interface WorktreeSession {
|
|
|
13
13
|
* @param cwd Current working directory
|
|
14
14
|
* @param options Optional creation options
|
|
15
15
|
* @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
|
|
16
|
+
* @param options.baseBranch Explicit base branch (overrides baseRef)
|
|
16
17
|
* @returns Worktree session details
|
|
17
18
|
*/
|
|
18
19
|
export declare function createWorktree(name: string, cwd: string, options?: {
|
|
19
20
|
baseRef?: "fresh" | "head";
|
|
20
|
-
|
|
21
|
+
baseBranch?: string;
|
|
22
|
+
}): Promise<WorktreeSession>;
|
|
21
23
|
/**
|
|
22
24
|
* Remove a git worktree and its associated branch
|
|
23
25
|
* @param session Worktree session details
|
|
24
26
|
*/
|
|
25
|
-
export declare function removeWorktree(session: WorktreeSession): void
|
|
27
|
+
export declare function removeWorktree(session: WorktreeSession): Promise<void>;
|
package/dist/utils/worktree.js
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import * as fs from "node:fs";
|
|
4
5
|
import { getDefaultRemoteBranch, getGitMainRepoRoot } from "wave-agent-sdk";
|
|
6
|
+
// Never use execFileSync here: the shared `wave --stdio` process handles all
|
|
7
|
+
// desktop sessions, so a synchronous git call (especially a multi-second
|
|
8
|
+
// recursive worktree delete or a network fetch) freezes every session.
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
5
10
|
/**
|
|
6
11
|
* Create a new git worktree
|
|
7
12
|
* @param name Worktree name
|
|
8
13
|
* @param cwd Current working directory
|
|
9
14
|
* @param options Optional creation options
|
|
10
15
|
* @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
|
|
16
|
+
* @param options.baseBranch Explicit base branch (overrides baseRef)
|
|
11
17
|
* @returns Worktree session details
|
|
12
18
|
*/
|
|
13
|
-
export function createWorktree(name, cwd, options) {
|
|
19
|
+
export async function createWorktree(name, cwd, options) {
|
|
14
20
|
const repoRoot = getGitMainRepoRoot(cwd);
|
|
15
21
|
const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
|
|
16
22
|
const branchName = `worktree-${name}`;
|
|
17
23
|
const useHead = options?.baseRef === "head";
|
|
18
|
-
const
|
|
24
|
+
const resolvedBaseBranch = options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
|
|
19
25
|
// Ensure parent directory exists
|
|
20
26
|
const parentDir = path.dirname(worktreePath);
|
|
21
27
|
if (!fs.existsSync(parentDir)) {
|
|
@@ -36,9 +42,8 @@ export function createWorktree(name, cwd, options) {
|
|
|
36
42
|
}
|
|
37
43
|
try {
|
|
38
44
|
// Create worktree and branch
|
|
39
|
-
|
|
45
|
+
await execFileAsync("git", ["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch], {
|
|
40
46
|
cwd: repoRoot,
|
|
41
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
42
47
|
});
|
|
43
48
|
return {
|
|
44
49
|
name,
|
|
@@ -55,9 +60,8 @@ export function createWorktree(name, cwd, options) {
|
|
|
55
60
|
if (stderr.includes("already exists")) {
|
|
56
61
|
// If branch already exists, try to add worktree without -b
|
|
57
62
|
try {
|
|
58
|
-
|
|
63
|
+
await execFileAsync("git", ["worktree", "add", worktreePath, branchName], {
|
|
59
64
|
cwd: repoRoot,
|
|
60
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
61
65
|
});
|
|
62
66
|
return {
|
|
63
67
|
name,
|
|
@@ -77,15 +81,20 @@ export function createWorktree(name, cwd, options) {
|
|
|
77
81
|
(stderr.includes("not a valid object name") ||
|
|
78
82
|
stderr.includes("unknown revision"))) {
|
|
79
83
|
// Base branch not fetched yet — try fetching then retrying
|
|
80
|
-
const branchNameOnly =
|
|
84
|
+
const branchNameOnly = resolvedBaseBranch.split("/").pop();
|
|
81
85
|
try {
|
|
82
|
-
|
|
86
|
+
await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
|
|
83
87
|
cwd: repoRoot,
|
|
84
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
85
88
|
});
|
|
86
|
-
|
|
89
|
+
await execFileAsync("git", [
|
|
90
|
+
"worktree",
|
|
91
|
+
"add",
|
|
92
|
+
"-b",
|
|
93
|
+
branchName,
|
|
94
|
+
worktreePath,
|
|
95
|
+
resolvedBaseBranch,
|
|
96
|
+
], {
|
|
87
97
|
cwd: repoRoot,
|
|
88
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
89
98
|
});
|
|
90
99
|
return {
|
|
91
100
|
name,
|
|
@@ -100,9 +109,8 @@ export function createWorktree(name, cwd, options) {
|
|
|
100
109
|
catch {
|
|
101
110
|
// Fetch or retry failed — fall back to HEAD
|
|
102
111
|
try {
|
|
103
|
-
|
|
112
|
+
await execFileAsync("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], {
|
|
104
113
|
cwd: repoRoot,
|
|
105
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
106
114
|
});
|
|
107
115
|
return {
|
|
108
116
|
name,
|
|
@@ -126,31 +134,29 @@ export function createWorktree(name, cwd, options) {
|
|
|
126
134
|
* Remove a git worktree and its associated branch
|
|
127
135
|
* @param session Worktree session details
|
|
128
136
|
*/
|
|
129
|
-
export function removeWorktree(session) {
|
|
137
|
+
export async function removeWorktree(session) {
|
|
130
138
|
const repoRoot = session.repoRoot;
|
|
131
139
|
try {
|
|
132
140
|
// Get current branch in worktree before removing it
|
|
133
141
|
let currentBranch;
|
|
134
142
|
try {
|
|
135
|
-
|
|
143
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
136
144
|
cwd: session.path,
|
|
137
145
|
encoding: "utf8",
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
});
|
|
147
|
+
currentBranch = stdout.trim();
|
|
140
148
|
}
|
|
141
149
|
catch {
|
|
142
150
|
// Ignore errors getting current branch
|
|
143
151
|
}
|
|
144
152
|
// Remove worktree
|
|
145
|
-
|
|
153
|
+
await execFileAsync("git", ["worktree", "remove", "--force", session.path], {
|
|
146
154
|
cwd: repoRoot,
|
|
147
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
148
155
|
});
|
|
149
156
|
// Delete original branch
|
|
150
157
|
try {
|
|
151
|
-
|
|
158
|
+
await execFileAsync("git", ["branch", "-D", session.branch], {
|
|
152
159
|
cwd: repoRoot,
|
|
153
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
154
160
|
});
|
|
155
161
|
}
|
|
156
162
|
catch {
|
|
@@ -166,9 +172,8 @@ export function removeWorktree(session) {
|
|
|
166
172
|
currentBranch !== "main" &&
|
|
167
173
|
currentBranch !== "master") {
|
|
168
174
|
try {
|
|
169
|
-
|
|
175
|
+
await execFileAsync("git", ["branch", "-D", currentBranch], {
|
|
170
176
|
cwd: repoRoot,
|
|
171
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
172
177
|
});
|
|
173
178
|
}
|
|
174
179
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-code",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.9",
|
|
4
4
|
"description": "CLI-based code assistant powered by AI, built with React and Ink",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"semver": "^7.7.4",
|
|
42
42
|
"yargs": "^17.7.2",
|
|
43
43
|
"zod": "^3.23.8",
|
|
44
|
-
"wave-agent-sdk": "0.19.
|
|
44
|
+
"wave-agent-sdk": "0.19.9"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/react": "^19.1.8",
|
package/src/cli.tsx
CHANGED
|
@@ -70,7 +70,7 @@ export async function startCli(options: CliOptions): Promise<void> {
|
|
|
70
70
|
// Cleanup worktree if requested
|
|
71
71
|
if (shouldRemoveWorktree && worktreeSession) {
|
|
72
72
|
process.chdir(worktreeSession.repoRoot);
|
|
73
|
-
removeWorktree(worktreeSession);
|
|
73
|
+
await removeWorktree(worktreeSession);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
process.exit(0);
|
|
@@ -99,12 +99,9 @@ export const TaskList: React.FC = () => {
|
|
|
99
99
|
const autoHideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
|
100
100
|
null,
|
|
101
101
|
);
|
|
102
|
-
const [autoHidden, setAutoHidden] = React.useState(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const active = tasks.filter((t) => t.status !== "deleted");
|
|
106
|
-
return active.length > 0 && active.every((t) => t.status === "completed");
|
|
107
|
-
});
|
|
102
|
+
const [autoHidden, setAutoHidden] = React.useState(false);
|
|
103
|
+
// 是否在当前展示期间观察到过未完成任务;用于区分“任务在眼前完成”与“加载时已全部完成”
|
|
104
|
+
const hadIncompleteRef = React.useRef(false);
|
|
108
105
|
const [, forceUpdate] = React.useState(0);
|
|
109
106
|
|
|
110
107
|
const now = Date.now();
|
|
@@ -155,24 +152,45 @@ export const TaskList: React.FC = () => {
|
|
|
155
152
|
activeTasks.length > 0 &&
|
|
156
153
|
activeTasks.every((t) => t.status === "completed");
|
|
157
154
|
|
|
155
|
+
// 观察到任务从“存在未完成”变为“全部完成”时保留 5 秒再隐藏;
|
|
156
|
+
// 加载或恢复到已全部完成的会话时立即隐藏,避免先闪现再消失
|
|
158
157
|
React.useEffect(() => {
|
|
159
|
-
if (
|
|
158
|
+
if (activeTasks.length === 0) {
|
|
159
|
+
hadIncompleteRef.current = false;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!allCompleted) {
|
|
163
|
+
hadIncompleteRef.current = true;
|
|
164
|
+
if (autoHidden) {
|
|
165
|
+
setAutoHidden(false);
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!hadIncompleteRef.current) {
|
|
170
|
+
if (!autoHidden) {
|
|
171
|
+
setAutoHidden(true);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!autoHidden) {
|
|
160
176
|
autoHideTimerRef.current = setTimeout(() => {
|
|
161
177
|
setAutoHidden(true);
|
|
162
178
|
}, AUTO_HIDE_DELAY_MS);
|
|
163
179
|
}
|
|
164
|
-
if (!allCompleted && autoHidden) {
|
|
165
|
-
setAutoHidden(false);
|
|
166
|
-
}
|
|
167
180
|
return () => {
|
|
168
181
|
if (autoHideTimerRef.current) {
|
|
169
182
|
clearTimeout(autoHideTimerRef.current);
|
|
170
183
|
autoHideTimerRef.current = null;
|
|
171
184
|
}
|
|
172
185
|
};
|
|
173
|
-
}, [allCompleted, autoHidden]);
|
|
186
|
+
}, [allCompleted, autoHidden, activeTasks.length]);
|
|
174
187
|
|
|
175
|
-
if (
|
|
188
|
+
if (
|
|
189
|
+
tasks.length === 0 ||
|
|
190
|
+
!isTaskListVisible ||
|
|
191
|
+
autoHidden ||
|
|
192
|
+
(allCompleted && !hadIncompleteRef.current)
|
|
193
|
+
) {
|
|
176
194
|
return null;
|
|
177
195
|
}
|
|
178
196
|
|
package/src/index.ts
CHANGED
|
@@ -326,7 +326,7 @@ export async function main() {
|
|
|
326
326
|
name = generateRandomName();
|
|
327
327
|
}
|
|
328
328
|
const baseRef = loadMergedWaveConfig(originalCwd)?.worktree?.baseRef;
|
|
329
|
-
worktreeSession = createWorktree(name, originalCwd, { baseRef });
|
|
329
|
+
worktreeSession = await createWorktree(name, originalCwd, { baseRef });
|
|
330
330
|
|
|
331
331
|
// Note: the full worktree session (originalCwd etc.) is injected into the
|
|
332
332
|
// agent's DI container after the agent is created in useChat.tsx. This keeps
|
package/src/print-cli.ts
CHANGED
|
@@ -194,7 +194,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
194
194
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
195
195
|
|
|
196
196
|
if (!hasChanges && !hasCommits) {
|
|
197
|
-
removeWorktree(worktreeSession);
|
|
197
|
+
await removeWorktree(worktreeSession);
|
|
198
198
|
} else {
|
|
199
199
|
process.stdout.write(
|
|
200
200
|
`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
|
|
@@ -230,7 +230,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
230
230
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
231
231
|
|
|
232
232
|
if (!hasChanges && !hasCommits) {
|
|
233
|
-
removeWorktree(worktreeSession);
|
|
233
|
+
await removeWorktree(worktreeSession);
|
|
234
234
|
} else {
|
|
235
235
|
process.stdout.write(
|
|
236
236
|
`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
|
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -34,6 +34,9 @@ import {
|
|
|
34
34
|
type Scope,
|
|
35
35
|
listSessions,
|
|
36
36
|
searchFiles,
|
|
37
|
+
generateRandomName,
|
|
38
|
+
getDefaultRemoteBranch,
|
|
39
|
+
getMessageContent,
|
|
37
40
|
PromptHistoryManager,
|
|
38
41
|
AuthService,
|
|
39
42
|
PluginCore,
|
|
@@ -44,6 +47,8 @@ import {
|
|
|
44
47
|
INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
|
|
45
48
|
METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
|
|
46
49
|
} from "./protocol.js";
|
|
50
|
+
import { execFileSync } from "node:child_process";
|
|
51
|
+
import { createWorktree, removeWorktree } from "../utils/worktree.js";
|
|
47
52
|
import { logger } from "../utils/logger.js";
|
|
48
53
|
|
|
49
54
|
export type NotificationEmitter = (
|
|
@@ -72,6 +77,8 @@ interface InitializeParams {
|
|
|
72
77
|
disallowedTools?: string[];
|
|
73
78
|
pluginDirs?: string[];
|
|
74
79
|
mcpServers?: Record<string, McpServerConfig>;
|
|
80
|
+
worktreeName?: string;
|
|
81
|
+
isNewWorktree?: boolean;
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
interface UpdateConfigParams {
|
|
@@ -163,6 +170,8 @@ export class AgentBridge {
|
|
|
163
170
|
return this.clearMessages(sessionId);
|
|
164
171
|
case "rewindToMessage":
|
|
165
172
|
return this.rewindToMessage(p.messageId as string, sessionId);
|
|
173
|
+
case "listRewindCheckpoints":
|
|
174
|
+
return this.listRewindCheckpoints(sessionId);
|
|
166
175
|
case "deleteQueuedMessage":
|
|
167
176
|
return this.deleteQueuedMessage(p.index as number, sessionId);
|
|
168
177
|
case "updateQueuedMessage":
|
|
@@ -250,6 +259,19 @@ export class AgentBridge {
|
|
|
250
259
|
p.workdir as string | undefined,
|
|
251
260
|
sessionId,
|
|
252
261
|
);
|
|
262
|
+
case "getProjectSettings":
|
|
263
|
+
return this.getProjectSettings(
|
|
264
|
+
p.workdir as string | undefined,
|
|
265
|
+
sessionId,
|
|
266
|
+
);
|
|
267
|
+
case "setBuiltinPluginEnabled":
|
|
268
|
+
return this.setBuiltinPluginEnabled(
|
|
269
|
+
p.pluginId as string,
|
|
270
|
+
p.enabled as boolean,
|
|
271
|
+
p.scope as Scope | undefined,
|
|
272
|
+
p.workdir as string | undefined,
|
|
273
|
+
sessionId,
|
|
274
|
+
);
|
|
253
275
|
case "updatePlugin":
|
|
254
276
|
return this.updatePlugin(
|
|
255
277
|
p.pluginId as string,
|
|
@@ -298,6 +320,22 @@ export class AgentBridge {
|
|
|
298
320
|
case "stopWorkflowRun":
|
|
299
321
|
return this.stopWorkflowRun(p.runId as string, sessionId);
|
|
300
322
|
|
|
323
|
+
// ── Git / worktree (global — no session required) ──
|
|
324
|
+
case "listGitBranches":
|
|
325
|
+
return this.listGitBranches(p.workdir as string | undefined);
|
|
326
|
+
case "createWorktree":
|
|
327
|
+
return this.createWorktreeSession(
|
|
328
|
+
p as unknown as {
|
|
329
|
+
workdir: string;
|
|
330
|
+
baseBranch?: string;
|
|
331
|
+
name?: string;
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
case "removeWorktree":
|
|
335
|
+
return this.removeWorktreeSession(
|
|
336
|
+
p as unknown as { path: string; branch: string; repoRoot: string },
|
|
337
|
+
);
|
|
338
|
+
|
|
301
339
|
default:
|
|
302
340
|
throw new RpcError(
|
|
303
341
|
PROTOCOL_METHOD_NOT_FOUND,
|
|
@@ -349,6 +387,8 @@ export class AgentBridge {
|
|
|
349
387
|
disallowedTools: params.disallowedTools,
|
|
350
388
|
plugins: params.pluginDirs?.map((path) => ({ type: "local", path })),
|
|
351
389
|
mcpServers: params.mcpServers,
|
|
390
|
+
worktreeName: params.worktreeName,
|
|
391
|
+
isNewWorktree: params.isNewWorktree,
|
|
352
392
|
canUseTool: (context: ToolPermissionContext) =>
|
|
353
393
|
this.canUseTool(context, ctx),
|
|
354
394
|
};
|
|
@@ -400,6 +440,106 @@ export class AgentBridge {
|
|
|
400
440
|
return { sessions };
|
|
401
441
|
}
|
|
402
442
|
|
|
443
|
+
// ── Git / worktree ────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
private listGitBranches(workdir?: string): {
|
|
446
|
+
branches: string[];
|
|
447
|
+
current: string | null;
|
|
448
|
+
} {
|
|
449
|
+
if (!workdir) {
|
|
450
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
451
|
+
}
|
|
452
|
+
const gitOpts = {
|
|
453
|
+
cwd: workdir,
|
|
454
|
+
encoding: "utf8" as const,
|
|
455
|
+
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
456
|
+
};
|
|
457
|
+
let branchesRaw: string;
|
|
458
|
+
try {
|
|
459
|
+
branchesRaw = execFileSync(
|
|
460
|
+
"git",
|
|
461
|
+
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
|
462
|
+
gitOpts,
|
|
463
|
+
).trim();
|
|
464
|
+
} catch {
|
|
465
|
+
throw new RpcError(
|
|
466
|
+
PROTOCOL_INTERNAL_ERROR,
|
|
467
|
+
`Not a git repository (or git unavailable): ${workdir}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const branches = branchesRaw
|
|
471
|
+
? branchesRaw
|
|
472
|
+
.split("\n")
|
|
473
|
+
.map((b) => b.trim())
|
|
474
|
+
.filter(Boolean)
|
|
475
|
+
: [];
|
|
476
|
+
let current: string | null = null;
|
|
477
|
+
try {
|
|
478
|
+
const head = execFileSync(
|
|
479
|
+
"git",
|
|
480
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
481
|
+
gitOpts,
|
|
482
|
+
).trim();
|
|
483
|
+
// Detached HEAD prints "HEAD" — treat as no current branch.
|
|
484
|
+
current = head && head !== "HEAD" ? head : null;
|
|
485
|
+
} catch {
|
|
486
|
+
current = null;
|
|
487
|
+
}
|
|
488
|
+
return { branches, current };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async createWorktreeSession(params: {
|
|
492
|
+
workdir: string;
|
|
493
|
+
baseBranch?: string;
|
|
494
|
+
name?: string;
|
|
495
|
+
}): Promise<{
|
|
496
|
+
name: string;
|
|
497
|
+
path: string;
|
|
498
|
+
branch: string;
|
|
499
|
+
repoRoot: string;
|
|
500
|
+
baseBranch: string;
|
|
501
|
+
isNew: boolean;
|
|
502
|
+
}> {
|
|
503
|
+
if (!params.workdir) {
|
|
504
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
505
|
+
}
|
|
506
|
+
const name = params.name?.trim() || generateRandomName();
|
|
507
|
+
try {
|
|
508
|
+
const session = await createWorktree(name, params.workdir, {
|
|
509
|
+
baseBranch: params.baseBranch,
|
|
510
|
+
});
|
|
511
|
+
return {
|
|
512
|
+
name: session.name,
|
|
513
|
+
path: session.path,
|
|
514
|
+
branch: session.branch,
|
|
515
|
+
repoRoot: session.repoRoot,
|
|
516
|
+
baseBranch: params.baseBranch ?? getDefaultRemoteBranch(params.workdir),
|
|
517
|
+
isNew: session.isNew,
|
|
518
|
+
};
|
|
519
|
+
} catch (e) {
|
|
520
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, (e as Error).message);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private async removeWorktreeSession(params: {
|
|
525
|
+
path: string;
|
|
526
|
+
branch: string;
|
|
527
|
+
repoRoot: string;
|
|
528
|
+
}): Promise<{ ok: true }> {
|
|
529
|
+
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
530
|
+
// branches only log, never throw.
|
|
531
|
+
await removeWorktree({
|
|
532
|
+
name: "",
|
|
533
|
+
path: params.path,
|
|
534
|
+
branch: params.branch,
|
|
535
|
+
repoRoot: params.repoRoot,
|
|
536
|
+
hasUncommittedChanges: false,
|
|
537
|
+
hasNewCommits: false,
|
|
538
|
+
isNew: false,
|
|
539
|
+
});
|
|
540
|
+
return { ok: true };
|
|
541
|
+
}
|
|
542
|
+
|
|
403
543
|
private getSessionInfo(sessionId?: string): {
|
|
404
544
|
sessionId: string;
|
|
405
545
|
workingDirectory: string;
|
|
@@ -450,6 +590,9 @@ export class AgentBridge {
|
|
|
450
590
|
path: p,
|
|
451
591
|
})),
|
|
452
592
|
mcpServers: entry.storedConfig.mcpServers,
|
|
593
|
+
// Keep worktree context (permission safety) across recreation, but never
|
|
594
|
+
// re-fire WorktreeCreate — the hook ran at initial creation.
|
|
595
|
+
worktreeName: entry.storedConfig.worktreeName,
|
|
453
596
|
canUseTool: (context: ToolPermissionContext) =>
|
|
454
597
|
this.canUseTool(context, ctx),
|
|
455
598
|
};
|
|
@@ -535,6 +678,20 @@ export class AgentBridge {
|
|
|
535
678
|
return { inputContent: textBlock?.content || "" };
|
|
536
679
|
}
|
|
537
680
|
|
|
681
|
+
private async listRewindCheckpoints(sessionId?: string): Promise<{
|
|
682
|
+
checkpoints: Array<{ id: string; content: string }>;
|
|
683
|
+
}> {
|
|
684
|
+
const entry = this.requireSession(sessionId);
|
|
685
|
+
const { messages } = await entry.agent.getFullMessageThread();
|
|
686
|
+
const checkpoints = messages
|
|
687
|
+
.filter((m) => m.role === "user" && !m.isMeta && m.id)
|
|
688
|
+
.map((m) => ({
|
|
689
|
+
id: m.id as string,
|
|
690
|
+
content: getMessageContent(m).replace(/\s+/g, " ").trim(),
|
|
691
|
+
}));
|
|
692
|
+
return { checkpoints };
|
|
693
|
+
}
|
|
694
|
+
|
|
538
695
|
private deleteQueuedMessage(index: number, sessionId?: string): null {
|
|
539
696
|
const entry = this.requireSession(sessionId);
|
|
540
697
|
entry.agent.removeQueuedMessage(index);
|
|
@@ -837,6 +994,35 @@ export class AgentBridge {
|
|
|
837
994
|
);
|
|
838
995
|
}
|
|
839
996
|
|
|
997
|
+
private async getProjectSettings(
|
|
998
|
+
workdir?: string,
|
|
999
|
+
sessionId?: string,
|
|
1000
|
+
): Promise<{ enabledPlugins: Record<string, boolean> }> {
|
|
1001
|
+
return {
|
|
1002
|
+
enabledPlugins: this.getPluginCore(
|
|
1003
|
+
workdir,
|
|
1004
|
+
sessionId,
|
|
1005
|
+
).getMergedEnabledPlugins(),
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private async setBuiltinPluginEnabled(
|
|
1010
|
+
pluginId: string,
|
|
1011
|
+
enabled: boolean,
|
|
1012
|
+
scope: Scope | undefined,
|
|
1013
|
+
workdir?: string,
|
|
1014
|
+
sessionId?: string,
|
|
1015
|
+
): Promise<{ enabledPlugins: Record<string, boolean> }> {
|
|
1016
|
+
const core = this.getPluginCore(workdir, sessionId);
|
|
1017
|
+
const targetScope = scope ?? "project";
|
|
1018
|
+
if (enabled) {
|
|
1019
|
+
await core.enablePlugin(pluginId, targetScope);
|
|
1020
|
+
} else {
|
|
1021
|
+
await core.disablePlugin(pluginId, targetScope);
|
|
1022
|
+
}
|
|
1023
|
+
return { enabledPlugins: core.getMergedEnabledPlugins() };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
840
1026
|
private async updatePlugin(
|
|
841
1027
|
pluginId: string,
|
|
842
1028
|
workdir?: string,
|
package/src/stdio/protocol.ts
CHANGED
|
@@ -57,6 +57,7 @@ export type RequestMethod =
|
|
|
57
57
|
| "abortMessage"
|
|
58
58
|
| "clearMessages"
|
|
59
59
|
| "rewindToMessage"
|
|
60
|
+
| "listRewindCheckpoints"
|
|
60
61
|
| "deleteQueuedMessage"
|
|
61
62
|
| "updateQueuedMessage"
|
|
62
63
|
| "deleteQueuedMessageById"
|
|
@@ -91,7 +92,11 @@ export type RequestMethod =
|
|
|
91
92
|
| "getBackgroundTaskOutput"
|
|
92
93
|
| "stopBackgroundTask"
|
|
93
94
|
| "getWorkflowRuns"
|
|
94
|
-
| "stopWorkflowRun"
|
|
95
|
+
| "stopWorkflowRun"
|
|
96
|
+
// Git / worktree (global — no session required)
|
|
97
|
+
| "listGitBranches"
|
|
98
|
+
| "createWorktree"
|
|
99
|
+
| "removeWorktree";
|
|
95
100
|
|
|
96
101
|
// ── Client → Server notification methods ────────────────────────
|
|
97
102
|
|
package/src/utils/worktree.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import * as fs from "node:fs";
|
|
4
5
|
import { getDefaultRemoteBranch, getGitMainRepoRoot } from "wave-agent-sdk";
|
|
5
6
|
|
|
7
|
+
// Never use execFileSync here: the shared `wave --stdio` process handles all
|
|
8
|
+
// desktop sessions, so a synchronous git call (especially a multi-second
|
|
9
|
+
// recursive worktree delete or a network fetch) freezes every session.
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
6
12
|
export interface WorktreeSession {
|
|
7
13
|
name: string;
|
|
8
14
|
path: string;
|
|
@@ -19,18 +25,20 @@ export interface WorktreeSession {
|
|
|
19
25
|
* @param cwd Current working directory
|
|
20
26
|
* @param options Optional creation options
|
|
21
27
|
* @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
|
|
28
|
+
* @param options.baseBranch Explicit base branch (overrides baseRef)
|
|
22
29
|
* @returns Worktree session details
|
|
23
30
|
*/
|
|
24
|
-
export function createWorktree(
|
|
31
|
+
export async function createWorktree(
|
|
25
32
|
name: string,
|
|
26
33
|
cwd: string,
|
|
27
|
-
options?: { baseRef?: "fresh" | "head" },
|
|
28
|
-
): WorktreeSession {
|
|
34
|
+
options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
|
|
35
|
+
): Promise<WorktreeSession> {
|
|
29
36
|
const repoRoot = getGitMainRepoRoot(cwd);
|
|
30
37
|
const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
|
|
31
38
|
const branchName = `worktree-${name}`;
|
|
32
39
|
const useHead = options?.baseRef === "head";
|
|
33
|
-
const
|
|
40
|
+
const resolvedBaseBranch =
|
|
41
|
+
options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
|
|
34
42
|
|
|
35
43
|
// Ensure parent directory exists
|
|
36
44
|
const parentDir = path.dirname(worktreePath);
|
|
@@ -54,12 +62,11 @@ export function createWorktree(
|
|
|
54
62
|
|
|
55
63
|
try {
|
|
56
64
|
// Create worktree and branch
|
|
57
|
-
|
|
65
|
+
await execFileAsync(
|
|
58
66
|
"git",
|
|
59
|
-
["worktree", "add", "-b", branchName, worktreePath,
|
|
67
|
+
["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch],
|
|
60
68
|
{
|
|
61
69
|
cwd: repoRoot,
|
|
62
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
63
70
|
},
|
|
64
71
|
);
|
|
65
72
|
|
|
@@ -73,14 +80,18 @@ export function createWorktree(
|
|
|
73
80
|
isNew: true,
|
|
74
81
|
};
|
|
75
82
|
} catch (error: unknown) {
|
|
76
|
-
const stderr =
|
|
83
|
+
const stderr =
|
|
84
|
+
(error as { stderr?: Buffer | string }).stderr?.toString() || "";
|
|
77
85
|
if (stderr.includes("already exists")) {
|
|
78
86
|
// If branch already exists, try to add worktree without -b
|
|
79
87
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
await execFileAsync(
|
|
89
|
+
"git",
|
|
90
|
+
["worktree", "add", worktreePath, branchName],
|
|
91
|
+
{
|
|
92
|
+
cwd: repoRoot,
|
|
93
|
+
},
|
|
94
|
+
);
|
|
84
95
|
return {
|
|
85
96
|
name,
|
|
86
97
|
path: worktreePath,
|
|
@@ -102,18 +113,23 @@ export function createWorktree(
|
|
|
102
113
|
stderr.includes("unknown revision"))
|
|
103
114
|
) {
|
|
104
115
|
// Base branch not fetched yet — try fetching then retrying
|
|
105
|
-
const branchNameOnly =
|
|
116
|
+
const branchNameOnly = resolvedBaseBranch.split("/").pop()!;
|
|
106
117
|
try {
|
|
107
|
-
|
|
118
|
+
await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
|
|
108
119
|
cwd: repoRoot,
|
|
109
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
110
120
|
});
|
|
111
|
-
|
|
121
|
+
await execFileAsync(
|
|
112
122
|
"git",
|
|
113
|
-
[
|
|
123
|
+
[
|
|
124
|
+
"worktree",
|
|
125
|
+
"add",
|
|
126
|
+
"-b",
|
|
127
|
+
branchName,
|
|
128
|
+
worktreePath,
|
|
129
|
+
resolvedBaseBranch,
|
|
130
|
+
],
|
|
114
131
|
{
|
|
115
132
|
cwd: repoRoot,
|
|
116
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
117
133
|
},
|
|
118
134
|
);
|
|
119
135
|
return {
|
|
@@ -128,12 +144,11 @@ export function createWorktree(
|
|
|
128
144
|
} catch {
|
|
129
145
|
// Fetch or retry failed — fall back to HEAD
|
|
130
146
|
try {
|
|
131
|
-
|
|
147
|
+
await execFileAsync(
|
|
132
148
|
"git",
|
|
133
149
|
["worktree", "add", "-b", branchName, worktreePath, "HEAD"],
|
|
134
150
|
{
|
|
135
151
|
cwd: repoRoot,
|
|
136
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
137
152
|
},
|
|
138
153
|
);
|
|
139
154
|
return {
|
|
@@ -162,37 +177,39 @@ export function createWorktree(
|
|
|
162
177
|
* Remove a git worktree and its associated branch
|
|
163
178
|
* @param session Worktree session details
|
|
164
179
|
*/
|
|
165
|
-
export function removeWorktree(session: WorktreeSession): void {
|
|
180
|
+
export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
166
181
|
const repoRoot = session.repoRoot;
|
|
167
182
|
|
|
168
183
|
try {
|
|
169
184
|
// Get current branch in worktree before removing it
|
|
170
185
|
let currentBranch: string | undefined;
|
|
171
186
|
try {
|
|
172
|
-
|
|
187
|
+
const { stdout } = await execFileAsync(
|
|
173
188
|
"git",
|
|
174
189
|
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
175
190
|
{
|
|
176
191
|
cwd: session.path,
|
|
177
192
|
encoding: "utf8",
|
|
178
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
179
193
|
},
|
|
180
|
-
)
|
|
194
|
+
);
|
|
195
|
+
currentBranch = stdout.trim();
|
|
181
196
|
} catch {
|
|
182
197
|
// Ignore errors getting current branch
|
|
183
198
|
}
|
|
184
199
|
|
|
185
200
|
// Remove worktree
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
await execFileAsync(
|
|
202
|
+
"git",
|
|
203
|
+
["worktree", "remove", "--force", session.path],
|
|
204
|
+
{
|
|
205
|
+
cwd: repoRoot,
|
|
206
|
+
},
|
|
207
|
+
);
|
|
190
208
|
|
|
191
209
|
// Delete original branch
|
|
192
210
|
try {
|
|
193
|
-
|
|
211
|
+
await execFileAsync("git", ["branch", "-D", session.branch], {
|
|
194
212
|
cwd: repoRoot,
|
|
195
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
196
213
|
});
|
|
197
214
|
} catch {
|
|
198
215
|
// Ignore errors deleting original branch
|
|
@@ -213,9 +230,8 @@ export function removeWorktree(session: WorktreeSession): void {
|
|
|
213
230
|
currentBranch !== "master"
|
|
214
231
|
) {
|
|
215
232
|
try {
|
|
216
|
-
|
|
233
|
+
await execFileAsync("git", ["branch", "-D", currentBranch], {
|
|
217
234
|
cwd: repoRoot,
|
|
218
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
219
235
|
});
|
|
220
236
|
} catch {
|
|
221
237
|
// Ignore errors deleting current branch
|