wave-code 0.19.9 → 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/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
- // Destroy agent and exit after sendMessage completes
187
- await agent.destroy();
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 (!hasChanges && !hasCommits) {
197
- await removeWorktree(worktreeSession);
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
- await agent.destroy();
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 (!hasChanges && !hasCommits) {
233
- await removeWorktree(worktreeSession);
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
  }
@@ -40,6 +40,7 @@ import {
40
40
  PromptHistoryManager,
41
41
  AuthService,
42
42
  PluginCore,
43
+ validateWorktreeRemovalPath,
43
44
  type SlashCommand,
44
45
  } from "wave-agent-sdk";
45
46
  import {
@@ -50,6 +51,7 @@ import {
50
51
  import { execFileSync } from "node:child_process";
51
52
  import { createWorktree, removeWorktree } from "../utils/worktree.js";
52
53
  import { logger } from "../utils/logger.js";
54
+ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
53
55
 
54
56
  export type NotificationEmitter = (
55
57
  method: string,
@@ -526,6 +528,28 @@ export class AgentBridge {
526
528
  branch: string;
527
529
  repoRoot: string;
528
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
+
529
553
  // removeWorktree is best-effort/idempotent: already-removed worktrees or
530
554
  // branches only log, never throw.
531
555
  await removeWorktree({
@@ -684,7 +708,7 @@ export class AgentBridge {
684
708
  const entry = this.requireSession(sessionId);
685
709
  const { messages } = await entry.agent.getFullMessageThread();
686
710
  const checkpoints = messages
687
- .filter((m) => m.role === "user" && !m.isMeta && m.id)
711
+ .filter((m) => isUserCheckpointMessage(m) && m.id)
688
712
  .map((m) => ({
689
713
  id: m.id as string,
690
714
  content: getMessageContent(m).replace(/\s+/g, " ").trim(),
@@ -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
+ }
@@ -2,7 +2,11 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import * as path from "node:path";
4
4
  import * as fs from "node:fs";
5
- import { getDefaultRemoteBranch, getGitMainRepoRoot } from "wave-agent-sdk";
5
+ import {
6
+ getDefaultRemoteBranch,
7
+ getGitMainRepoRoot,
8
+ performPostCreationSetup,
9
+ } from "wave-agent-sdk";
6
10
 
7
11
  // Never use execFileSync here: the shared `wave --stdio` process handles all
8
12
  // desktop sessions, so a synchronous git call (especially a multi-second
@@ -19,6 +23,38 @@ export interface WorktreeSession {
19
23
  isNew: boolean;
20
24
  }
21
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
+
22
58
  /**
23
59
  * Create a new git worktree
24
60
  * @param name Worktree name
@@ -32,6 +68,19 @@ export async function createWorktree(
32
68
  name: string,
33
69
  cwd: string,
34
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(
81
+ name: string,
82
+ cwd: string,
83
+ options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
35
84
  ): Promise<WorktreeSession> {
36
85
  const repoRoot = getGitMainRepoRoot(cwd);
37
86
  const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);