wave-code 0.19.8 → 1.0.0
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/ChatInterface.js +1 -1
- package/dist/components/HelpView.js +6 -0
- package/dist/components/InputBox.d.ts +2 -0
- package/dist/components/InputBox.js +11 -2
- package/dist/components/RewindCommand.js +4 -2
- package/dist/components/TaskList.js +28 -12
- package/dist/hooks/useInputManager.d.ts +1 -0
- package/dist/hooks/useInputManager.js +23 -20
- package/dist/index.js +1 -1
- package/dist/managers/inputHandlers.js +5 -8
- package/dist/managers/inputReducer.d.ts +10 -20
- package/dist/managers/inputReducer.js +309 -173
- package/dist/print-cli.js +36 -10
- package/dist/stdio/agentBridge.d.ts +6 -0
- package/dist/stdio/agentBridge.js +141 -1
- package/dist/stdio/protocol.d.ts +1 -1
- package/dist/utils/rewindCheckpoints.d.ts +8 -0
- package/dist/utils/rewindCheckpoints.js +15 -0
- package/dist/utils/worktree.d.ts +12 -2
- package/dist/utils/worktree.js +61 -25
- package/package.json +2 -2
- package/src/cli.tsx +1 -1
- package/src/components/ChatInterface.tsx +2 -0
- package/src/components/HelpView.tsx +6 -0
- package/src/components/InputBox.tsx +19 -0
- package/src/components/RewindCommand.tsx +4 -2
- package/src/components/TaskList.tsx +30 -12
- package/src/hooks/useInputManager.ts +27 -21
- package/src/index.ts +1 -1
- package/src/managers/inputHandlers.ts +6 -10
- package/src/managers/inputReducer.ts +381 -208
- package/src/print-cli.ts +48 -11
- package/src/stdio/agentBridge.ts +210 -0
- package/src/stdio/protocol.ts +6 -1
- package/src/utils/rewindCheckpoints.ts +15 -0
- package/src/utils/worktree.ts +99 -34
package/src/print-cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
hasUncommittedChanges,
|
|
5
5
|
hasNewCommits,
|
|
6
6
|
getDefaultRemoteBranch,
|
|
7
|
+
validateWorktreeRemovalPath,
|
|
7
8
|
type WorktreeSession,
|
|
8
9
|
} from "wave-agent-sdk";
|
|
9
10
|
import { displayUsageSummary } from "./utils/usageSummary.js";
|
|
@@ -183,18 +184,18 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
183
184
|
// Display timing information
|
|
184
185
|
displayTimingInfo(startTime, showStats);
|
|
185
186
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Handle worktree cleanup for print mode
|
|
187
|
+
// Trigger WorktreeRemove hook (before destroy — it needs a live agent) and
|
|
188
|
+
// decide whether the worktree is clean enough to remove
|
|
189
|
+
let cleanWorktree = false;
|
|
190
190
|
if (worktreeSession) {
|
|
191
191
|
const cwd = workdir || worktreeSession.path;
|
|
192
192
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
193
193
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
194
194
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
195
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
195
196
|
|
|
196
|
-
if (
|
|
197
|
-
|
|
197
|
+
if (cleanWorktree) {
|
|
198
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
198
199
|
} else {
|
|
199
200
|
process.stdout.write(
|
|
200
201
|
`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
|
|
@@ -202,6 +203,25 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// Destroy agent and exit after sendMessage completes
|
|
207
|
+
await agent.destroy();
|
|
208
|
+
|
|
209
|
+
// Handle worktree cleanup for print mode (git removal stays after destroy)
|
|
210
|
+
if (worktreeSession && cleanWorktree) {
|
|
211
|
+
try {
|
|
212
|
+
validateWorktreeRemovalPath(
|
|
213
|
+
worktreeSession.path,
|
|
214
|
+
worktreeSession.repoRoot,
|
|
215
|
+
);
|
|
216
|
+
await removeWorktree(worktreeSession);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// Never block print-mode exit on worktree cleanup failures
|
|
219
|
+
process.stdout.write(
|
|
220
|
+
`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
205
225
|
process.exit(0);
|
|
206
226
|
} catch (error) {
|
|
207
227
|
console.error("Failed to send message:", error);
|
|
@@ -220,23 +240,40 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
220
240
|
// Display timing information even on error
|
|
221
241
|
displayTimingInfo(startTime, showStats);
|
|
222
242
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// Handle worktree cleanup for print mode even on error
|
|
243
|
+
// Trigger WorktreeRemove hook (before destroy) when the worktree is clean
|
|
244
|
+
let cleanWorktree = false;
|
|
226
245
|
if (worktreeSession) {
|
|
227
246
|
const cwd = workdir || worktreeSession.path;
|
|
228
247
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
229
248
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
230
249
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
250
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
231
251
|
|
|
232
|
-
if (
|
|
233
|
-
|
|
252
|
+
if (cleanWorktree) {
|
|
253
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
234
254
|
} else {
|
|
235
255
|
process.stdout.write(
|
|
236
256
|
`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
|
|
237
257
|
);
|
|
238
258
|
}
|
|
239
259
|
}
|
|
260
|
+
|
|
261
|
+
await agent.destroy();
|
|
262
|
+
|
|
263
|
+
// Handle worktree cleanup for print mode even on error
|
|
264
|
+
if (worktreeSession && cleanWorktree) {
|
|
265
|
+
try {
|
|
266
|
+
validateWorktreeRemovalPath(
|
|
267
|
+
worktreeSession.path,
|
|
268
|
+
worktreeSession.repoRoot,
|
|
269
|
+
);
|
|
270
|
+
await removeWorktree(worktreeSession);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
process.stdout.write(
|
|
273
|
+
`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
240
277
|
}
|
|
241
278
|
process.exit(1);
|
|
242
279
|
}
|
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -34,9 +34,13 @@ 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,
|
|
43
|
+
validateWorktreeRemovalPath,
|
|
40
44
|
type SlashCommand,
|
|
41
45
|
} from "wave-agent-sdk";
|
|
42
46
|
import {
|
|
@@ -44,7 +48,10 @@ import {
|
|
|
44
48
|
INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
|
|
45
49
|
METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
|
|
46
50
|
} from "./protocol.js";
|
|
51
|
+
import { execFileSync } from "node:child_process";
|
|
52
|
+
import { createWorktree, removeWorktree } from "../utils/worktree.js";
|
|
47
53
|
import { logger } from "../utils/logger.js";
|
|
54
|
+
import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
|
|
48
55
|
|
|
49
56
|
export type NotificationEmitter = (
|
|
50
57
|
method: string,
|
|
@@ -72,6 +79,8 @@ interface InitializeParams {
|
|
|
72
79
|
disallowedTools?: string[];
|
|
73
80
|
pluginDirs?: string[];
|
|
74
81
|
mcpServers?: Record<string, McpServerConfig>;
|
|
82
|
+
worktreeName?: string;
|
|
83
|
+
isNewWorktree?: boolean;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
interface UpdateConfigParams {
|
|
@@ -163,6 +172,8 @@ export class AgentBridge {
|
|
|
163
172
|
return this.clearMessages(sessionId);
|
|
164
173
|
case "rewindToMessage":
|
|
165
174
|
return this.rewindToMessage(p.messageId as string, sessionId);
|
|
175
|
+
case "listRewindCheckpoints":
|
|
176
|
+
return this.listRewindCheckpoints(sessionId);
|
|
166
177
|
case "deleteQueuedMessage":
|
|
167
178
|
return this.deleteQueuedMessage(p.index as number, sessionId);
|
|
168
179
|
case "updateQueuedMessage":
|
|
@@ -250,6 +261,19 @@ export class AgentBridge {
|
|
|
250
261
|
p.workdir as string | undefined,
|
|
251
262
|
sessionId,
|
|
252
263
|
);
|
|
264
|
+
case "getProjectSettings":
|
|
265
|
+
return this.getProjectSettings(
|
|
266
|
+
p.workdir as string | undefined,
|
|
267
|
+
sessionId,
|
|
268
|
+
);
|
|
269
|
+
case "setBuiltinPluginEnabled":
|
|
270
|
+
return this.setBuiltinPluginEnabled(
|
|
271
|
+
p.pluginId as string,
|
|
272
|
+
p.enabled as boolean,
|
|
273
|
+
p.scope as Scope | undefined,
|
|
274
|
+
p.workdir as string | undefined,
|
|
275
|
+
sessionId,
|
|
276
|
+
);
|
|
253
277
|
case "updatePlugin":
|
|
254
278
|
return this.updatePlugin(
|
|
255
279
|
p.pluginId as string,
|
|
@@ -298,6 +322,22 @@ export class AgentBridge {
|
|
|
298
322
|
case "stopWorkflowRun":
|
|
299
323
|
return this.stopWorkflowRun(p.runId as string, sessionId);
|
|
300
324
|
|
|
325
|
+
// ── Git / worktree (global — no session required) ──
|
|
326
|
+
case "listGitBranches":
|
|
327
|
+
return this.listGitBranches(p.workdir as string | undefined);
|
|
328
|
+
case "createWorktree":
|
|
329
|
+
return this.createWorktreeSession(
|
|
330
|
+
p as unknown as {
|
|
331
|
+
workdir: string;
|
|
332
|
+
baseBranch?: string;
|
|
333
|
+
name?: string;
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
case "removeWorktree":
|
|
337
|
+
return this.removeWorktreeSession(
|
|
338
|
+
p as unknown as { path: string; branch: string; repoRoot: string },
|
|
339
|
+
);
|
|
340
|
+
|
|
301
341
|
default:
|
|
302
342
|
throw new RpcError(
|
|
303
343
|
PROTOCOL_METHOD_NOT_FOUND,
|
|
@@ -349,6 +389,8 @@ export class AgentBridge {
|
|
|
349
389
|
disallowedTools: params.disallowedTools,
|
|
350
390
|
plugins: params.pluginDirs?.map((path) => ({ type: "local", path })),
|
|
351
391
|
mcpServers: params.mcpServers,
|
|
392
|
+
worktreeName: params.worktreeName,
|
|
393
|
+
isNewWorktree: params.isNewWorktree,
|
|
352
394
|
canUseTool: (context: ToolPermissionContext) =>
|
|
353
395
|
this.canUseTool(context, ctx),
|
|
354
396
|
};
|
|
@@ -400,6 +442,128 @@ export class AgentBridge {
|
|
|
400
442
|
return { sessions };
|
|
401
443
|
}
|
|
402
444
|
|
|
445
|
+
// ── Git / worktree ────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
private listGitBranches(workdir?: string): {
|
|
448
|
+
branches: string[];
|
|
449
|
+
current: string | null;
|
|
450
|
+
} {
|
|
451
|
+
if (!workdir) {
|
|
452
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
453
|
+
}
|
|
454
|
+
const gitOpts = {
|
|
455
|
+
cwd: workdir,
|
|
456
|
+
encoding: "utf8" as const,
|
|
457
|
+
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
458
|
+
};
|
|
459
|
+
let branchesRaw: string;
|
|
460
|
+
try {
|
|
461
|
+
branchesRaw = execFileSync(
|
|
462
|
+
"git",
|
|
463
|
+
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
|
|
464
|
+
gitOpts,
|
|
465
|
+
).trim();
|
|
466
|
+
} catch {
|
|
467
|
+
throw new RpcError(
|
|
468
|
+
PROTOCOL_INTERNAL_ERROR,
|
|
469
|
+
`Not a git repository (or git unavailable): ${workdir}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const branches = branchesRaw
|
|
473
|
+
? branchesRaw
|
|
474
|
+
.split("\n")
|
|
475
|
+
.map((b) => b.trim())
|
|
476
|
+
.filter(Boolean)
|
|
477
|
+
: [];
|
|
478
|
+
let current: string | null = null;
|
|
479
|
+
try {
|
|
480
|
+
const head = execFileSync(
|
|
481
|
+
"git",
|
|
482
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
483
|
+
gitOpts,
|
|
484
|
+
).trim();
|
|
485
|
+
// Detached HEAD prints "HEAD" — treat as no current branch.
|
|
486
|
+
current = head && head !== "HEAD" ? head : null;
|
|
487
|
+
} catch {
|
|
488
|
+
current = null;
|
|
489
|
+
}
|
|
490
|
+
return { branches, current };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async createWorktreeSession(params: {
|
|
494
|
+
workdir: string;
|
|
495
|
+
baseBranch?: string;
|
|
496
|
+
name?: string;
|
|
497
|
+
}): Promise<{
|
|
498
|
+
name: string;
|
|
499
|
+
path: string;
|
|
500
|
+
branch: string;
|
|
501
|
+
repoRoot: string;
|
|
502
|
+
baseBranch: string;
|
|
503
|
+
isNew: boolean;
|
|
504
|
+
}> {
|
|
505
|
+
if (!params.workdir) {
|
|
506
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
507
|
+
}
|
|
508
|
+
const name = params.name?.trim() || generateRandomName();
|
|
509
|
+
try {
|
|
510
|
+
const session = await createWorktree(name, params.workdir, {
|
|
511
|
+
baseBranch: params.baseBranch,
|
|
512
|
+
});
|
|
513
|
+
return {
|
|
514
|
+
name: session.name,
|
|
515
|
+
path: session.path,
|
|
516
|
+
branch: session.branch,
|
|
517
|
+
repoRoot: session.repoRoot,
|
|
518
|
+
baseBranch: params.baseBranch ?? getDefaultRemoteBranch(params.workdir),
|
|
519
|
+
isNew: session.isNew,
|
|
520
|
+
};
|
|
521
|
+
} catch (e) {
|
|
522
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, (e as Error).message);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private async removeWorktreeSession(params: {
|
|
527
|
+
path: string;
|
|
528
|
+
branch: string;
|
|
529
|
+
repoRoot: string;
|
|
530
|
+
}): Promise<{ ok: true }> {
|
|
531
|
+
// Align with Claude Code v2.1.216+: refuse to remove a worktree whose path
|
|
532
|
+
// is a symlink or resolves outside the repo root. Already-removed (missing)
|
|
533
|
+
// paths pass validation so removal stays idempotent.
|
|
534
|
+
try {
|
|
535
|
+
validateWorktreeRemovalPath(params.path, params.repoRoot);
|
|
536
|
+
} catch (e) {
|
|
537
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, (e as Error).message);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Trigger the WorktreeRemove hook (before git removal, non-blocking) using
|
|
541
|
+
// the session that runs in this worktree, if it is still registered.
|
|
542
|
+
for (const entry of this.sessions.values()) {
|
|
543
|
+
if (entry.agent.workingDirectory === params.path) {
|
|
544
|
+
try {
|
|
545
|
+
await entry.agent.triggerWorktreeRemoveHook(params.path);
|
|
546
|
+
} catch (e) {
|
|
547
|
+
logger.warn("WorktreeRemove hooks execution failed:", e);
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
554
|
+
// branches only log, never throw.
|
|
555
|
+
await removeWorktree({
|
|
556
|
+
name: "",
|
|
557
|
+
path: params.path,
|
|
558
|
+
branch: params.branch,
|
|
559
|
+
repoRoot: params.repoRoot,
|
|
560
|
+
hasUncommittedChanges: false,
|
|
561
|
+
hasNewCommits: false,
|
|
562
|
+
isNew: false,
|
|
563
|
+
});
|
|
564
|
+
return { ok: true };
|
|
565
|
+
}
|
|
566
|
+
|
|
403
567
|
private getSessionInfo(sessionId?: string): {
|
|
404
568
|
sessionId: string;
|
|
405
569
|
workingDirectory: string;
|
|
@@ -450,6 +614,9 @@ export class AgentBridge {
|
|
|
450
614
|
path: p,
|
|
451
615
|
})),
|
|
452
616
|
mcpServers: entry.storedConfig.mcpServers,
|
|
617
|
+
// Keep worktree context (permission safety) across recreation, but never
|
|
618
|
+
// re-fire WorktreeCreate — the hook ran at initial creation.
|
|
619
|
+
worktreeName: entry.storedConfig.worktreeName,
|
|
453
620
|
canUseTool: (context: ToolPermissionContext) =>
|
|
454
621
|
this.canUseTool(context, ctx),
|
|
455
622
|
};
|
|
@@ -535,6 +702,20 @@ export class AgentBridge {
|
|
|
535
702
|
return { inputContent: textBlock?.content || "" };
|
|
536
703
|
}
|
|
537
704
|
|
|
705
|
+
private async listRewindCheckpoints(sessionId?: string): Promise<{
|
|
706
|
+
checkpoints: Array<{ id: string; content: string }>;
|
|
707
|
+
}> {
|
|
708
|
+
const entry = this.requireSession(sessionId);
|
|
709
|
+
const { messages } = await entry.agent.getFullMessageThread();
|
|
710
|
+
const checkpoints = messages
|
|
711
|
+
.filter((m) => isUserCheckpointMessage(m) && m.id)
|
|
712
|
+
.map((m) => ({
|
|
713
|
+
id: m.id as string,
|
|
714
|
+
content: getMessageContent(m).replace(/\s+/g, " ").trim(),
|
|
715
|
+
}));
|
|
716
|
+
return { checkpoints };
|
|
717
|
+
}
|
|
718
|
+
|
|
538
719
|
private deleteQueuedMessage(index: number, sessionId?: string): null {
|
|
539
720
|
const entry = this.requireSession(sessionId);
|
|
540
721
|
entry.agent.removeQueuedMessage(index);
|
|
@@ -837,6 +1018,35 @@ export class AgentBridge {
|
|
|
837
1018
|
);
|
|
838
1019
|
}
|
|
839
1020
|
|
|
1021
|
+
private async getProjectSettings(
|
|
1022
|
+
workdir?: string,
|
|
1023
|
+
sessionId?: string,
|
|
1024
|
+
): Promise<{ enabledPlugins: Record<string, boolean> }> {
|
|
1025
|
+
return {
|
|
1026
|
+
enabledPlugins: this.getPluginCore(
|
|
1027
|
+
workdir,
|
|
1028
|
+
sessionId,
|
|
1029
|
+
).getMergedEnabledPlugins(),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private async setBuiltinPluginEnabled(
|
|
1034
|
+
pluginId: string,
|
|
1035
|
+
enabled: boolean,
|
|
1036
|
+
scope: Scope | undefined,
|
|
1037
|
+
workdir?: string,
|
|
1038
|
+
sessionId?: string,
|
|
1039
|
+
): Promise<{ enabledPlugins: Record<string, boolean> }> {
|
|
1040
|
+
const core = this.getPluginCore(workdir, sessionId);
|
|
1041
|
+
const targetScope = scope ?? "project";
|
|
1042
|
+
if (enabled) {
|
|
1043
|
+
await core.enablePlugin(pluginId, targetScope);
|
|
1044
|
+
} else {
|
|
1045
|
+
await core.disablePlugin(pluginId, targetScope);
|
|
1046
|
+
}
|
|
1047
|
+
return { enabledPlugins: core.getMergedEnabledPlugins() };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
840
1050
|
private async updatePlugin(
|
|
841
1051
|
pluginId: string,
|
|
842
1052
|
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
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Message } from "wave-agent-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 判断一条 user 消息能否作为 /rewind 检查点。
|
|
5
|
+
* 后台任务通知(task_notification)与 hook 注入的消息(source: "hook")
|
|
6
|
+
* 都是系统生成、用户不可见的,不能作为回滚点。
|
|
7
|
+
* CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
|
|
8
|
+
*/
|
|
9
|
+
export function isUserCheckpointMessage(m: Message): boolean {
|
|
10
|
+
if (m.role !== "user" || m.isMeta) return false;
|
|
11
|
+
if (m.blocks.some((b) => b.type === "task_notification")) return false;
|
|
12
|
+
if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
|
|
13
|
+
return false;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
package/src/utils/worktree.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
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
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getDefaultRemoteBranch,
|
|
7
|
+
getGitMainRepoRoot,
|
|
8
|
+
performPostCreationSetup,
|
|
9
|
+
} from "wave-agent-sdk";
|
|
10
|
+
|
|
11
|
+
// Never use execFileSync here: the shared `wave --stdio` process handles all
|
|
12
|
+
// desktop sessions, so a synchronous git call (especially a multi-second
|
|
13
|
+
// recursive worktree delete or a network fetch) freezes every session.
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
5
15
|
|
|
6
16
|
export interface WorktreeSession {
|
|
7
17
|
name: string;
|
|
@@ -13,24 +23,71 @@ export interface WorktreeSession {
|
|
|
13
23
|
isNew: boolean;
|
|
14
24
|
}
|
|
15
25
|
|
|
26
|
+
// --- Worktree name validation ------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
|
|
29
|
+
const MAX_WORKTREE_SLUG_LENGTH = 64;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate a worktree name before any side effects. Names are slash-separated
|
|
33
|
+
* slugs: every segment must be non-empty and contain only letters, digits,
|
|
34
|
+
* dots, underscores, and dashes. The total length is capped at 64 characters
|
|
35
|
+
* and "." / ".." segments are rejected (path traversal protection).
|
|
36
|
+
* @throws {Error} When the name is not a valid slug
|
|
37
|
+
*/
|
|
38
|
+
export function validateWorktreeSlug(name: string): void {
|
|
39
|
+
if (name.length > MAX_WORKTREE_SLUG_LENGTH) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Invalid worktree name: "${name}" must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${name.length})`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
for (const segment of name.split("/")) {
|
|
45
|
+
if (segment === "." || segment === "..") {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Invalid worktree name: "${name}" must not contain "." or ".." path segments`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (segment.length === 0 || !VALID_WORKTREE_SLUG_SEGMENT.test(segment)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Invalid worktree name: "${name}" each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
16
58
|
/**
|
|
17
59
|
* Create a new git worktree
|
|
18
60
|
* @param name Worktree name
|
|
19
61
|
* @param cwd Current working directory
|
|
20
62
|
* @param options Optional creation options
|
|
21
63
|
* @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
|
|
64
|
+
* @param options.baseBranch Explicit base branch (overrides baseRef)
|
|
22
65
|
* @returns Worktree session details
|
|
23
66
|
*/
|
|
24
|
-
export function createWorktree(
|
|
67
|
+
export async function createWorktree(
|
|
68
|
+
name: string,
|
|
69
|
+
cwd: string,
|
|
70
|
+
options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
|
|
71
|
+
): Promise<WorktreeSession> {
|
|
72
|
+
validateWorktreeSlug(name);
|
|
73
|
+
const session = await createWorktreeInternal(name, cwd, options);
|
|
74
|
+
if (session.isNew) {
|
|
75
|
+
await performPostCreationSetup(session.path, session.repoRoot);
|
|
76
|
+
}
|
|
77
|
+
return session;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function createWorktreeInternal(
|
|
25
81
|
name: string,
|
|
26
82
|
cwd: string,
|
|
27
|
-
options?: { baseRef?: "fresh" | "head" },
|
|
28
|
-
): WorktreeSession {
|
|
83
|
+
options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
|
|
84
|
+
): Promise<WorktreeSession> {
|
|
29
85
|
const repoRoot = getGitMainRepoRoot(cwd);
|
|
30
86
|
const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
|
|
31
87
|
const branchName = `worktree-${name}`;
|
|
32
88
|
const useHead = options?.baseRef === "head";
|
|
33
|
-
const
|
|
89
|
+
const resolvedBaseBranch =
|
|
90
|
+
options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
|
|
34
91
|
|
|
35
92
|
// Ensure parent directory exists
|
|
36
93
|
const parentDir = path.dirname(worktreePath);
|
|
@@ -54,12 +111,11 @@ export function createWorktree(
|
|
|
54
111
|
|
|
55
112
|
try {
|
|
56
113
|
// Create worktree and branch
|
|
57
|
-
|
|
114
|
+
await execFileAsync(
|
|
58
115
|
"git",
|
|
59
|
-
["worktree", "add", "-b", branchName, worktreePath,
|
|
116
|
+
["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch],
|
|
60
117
|
{
|
|
61
118
|
cwd: repoRoot,
|
|
62
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
63
119
|
},
|
|
64
120
|
);
|
|
65
121
|
|
|
@@ -73,14 +129,18 @@ export function createWorktree(
|
|
|
73
129
|
isNew: true,
|
|
74
130
|
};
|
|
75
131
|
} catch (error: unknown) {
|
|
76
|
-
const stderr =
|
|
132
|
+
const stderr =
|
|
133
|
+
(error as { stderr?: Buffer | string }).stderr?.toString() || "";
|
|
77
134
|
if (stderr.includes("already exists")) {
|
|
78
135
|
// If branch already exists, try to add worktree without -b
|
|
79
136
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
137
|
+
await execFileAsync(
|
|
138
|
+
"git",
|
|
139
|
+
["worktree", "add", worktreePath, branchName],
|
|
140
|
+
{
|
|
141
|
+
cwd: repoRoot,
|
|
142
|
+
},
|
|
143
|
+
);
|
|
84
144
|
return {
|
|
85
145
|
name,
|
|
86
146
|
path: worktreePath,
|
|
@@ -102,18 +162,23 @@ export function createWorktree(
|
|
|
102
162
|
stderr.includes("unknown revision"))
|
|
103
163
|
) {
|
|
104
164
|
// Base branch not fetched yet — try fetching then retrying
|
|
105
|
-
const branchNameOnly =
|
|
165
|
+
const branchNameOnly = resolvedBaseBranch.split("/").pop()!;
|
|
106
166
|
try {
|
|
107
|
-
|
|
167
|
+
await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
|
|
108
168
|
cwd: repoRoot,
|
|
109
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
110
169
|
});
|
|
111
|
-
|
|
170
|
+
await execFileAsync(
|
|
112
171
|
"git",
|
|
113
|
-
[
|
|
172
|
+
[
|
|
173
|
+
"worktree",
|
|
174
|
+
"add",
|
|
175
|
+
"-b",
|
|
176
|
+
branchName,
|
|
177
|
+
worktreePath,
|
|
178
|
+
resolvedBaseBranch,
|
|
179
|
+
],
|
|
114
180
|
{
|
|
115
181
|
cwd: repoRoot,
|
|
116
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
117
182
|
},
|
|
118
183
|
);
|
|
119
184
|
return {
|
|
@@ -128,12 +193,11 @@ export function createWorktree(
|
|
|
128
193
|
} catch {
|
|
129
194
|
// Fetch or retry failed — fall back to HEAD
|
|
130
195
|
try {
|
|
131
|
-
|
|
196
|
+
await execFileAsync(
|
|
132
197
|
"git",
|
|
133
198
|
["worktree", "add", "-b", branchName, worktreePath, "HEAD"],
|
|
134
199
|
{
|
|
135
200
|
cwd: repoRoot,
|
|
136
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
137
201
|
},
|
|
138
202
|
);
|
|
139
203
|
return {
|
|
@@ -162,37 +226,39 @@ export function createWorktree(
|
|
|
162
226
|
* Remove a git worktree and its associated branch
|
|
163
227
|
* @param session Worktree session details
|
|
164
228
|
*/
|
|
165
|
-
export function removeWorktree(session: WorktreeSession): void {
|
|
229
|
+
export async function removeWorktree(session: WorktreeSession): Promise<void> {
|
|
166
230
|
const repoRoot = session.repoRoot;
|
|
167
231
|
|
|
168
232
|
try {
|
|
169
233
|
// Get current branch in worktree before removing it
|
|
170
234
|
let currentBranch: string | undefined;
|
|
171
235
|
try {
|
|
172
|
-
|
|
236
|
+
const { stdout } = await execFileAsync(
|
|
173
237
|
"git",
|
|
174
238
|
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
175
239
|
{
|
|
176
240
|
cwd: session.path,
|
|
177
241
|
encoding: "utf8",
|
|
178
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
179
242
|
},
|
|
180
|
-
)
|
|
243
|
+
);
|
|
244
|
+
currentBranch = stdout.trim();
|
|
181
245
|
} catch {
|
|
182
246
|
// Ignore errors getting current branch
|
|
183
247
|
}
|
|
184
248
|
|
|
185
249
|
// Remove worktree
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
250
|
+
await execFileAsync(
|
|
251
|
+
"git",
|
|
252
|
+
["worktree", "remove", "--force", session.path],
|
|
253
|
+
{
|
|
254
|
+
cwd: repoRoot,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
190
257
|
|
|
191
258
|
// Delete original branch
|
|
192
259
|
try {
|
|
193
|
-
|
|
260
|
+
await execFileAsync("git", ["branch", "-D", session.branch], {
|
|
194
261
|
cwd: repoRoot,
|
|
195
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
196
262
|
});
|
|
197
263
|
} catch {
|
|
198
264
|
// Ignore errors deleting original branch
|
|
@@ -213,9 +279,8 @@ export function removeWorktree(session: WorktreeSession): void {
|
|
|
213
279
|
currentBranch !== "master"
|
|
214
280
|
) {
|
|
215
281
|
try {
|
|
216
|
-
|
|
282
|
+
await execFileAsync("git", ["branch", "-D", currentBranch], {
|
|
217
283
|
cwd: repoRoot,
|
|
218
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
219
284
|
});
|
|
220
285
|
} catch {
|
|
221
286
|
// Ignore errors deleting current branch
|