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.
@@ -14,11 +14,12 @@
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, generateRandomName, getDefaultRemoteBranch, getMessageContent, PromptHistoryManager, AuthService, PluginCore, } from "wave-agent-sdk";
17
+ import { Agent, listSessions, searchFiles, generateRandomName, getDefaultRemoteBranch, getMessageContent, PromptHistoryManager, AuthService, PluginCore, validateWorktreeRemovalPath, } 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
19
  import { execFileSync } from "node:child_process";
20
20
  import { createWorktree, removeWorktree } from "../utils/worktree.js";
21
21
  import { logger } from "../utils/logger.js";
22
+ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
22
23
  export class AgentBridge {
23
24
  constructor(options) {
24
25
  this.sessions = new Map();
@@ -268,6 +269,28 @@ export class AgentBridge {
268
269
  }
269
270
  }
270
271
  async removeWorktreeSession(params) {
272
+ // Align with Claude Code v2.1.216+: refuse to remove a worktree whose path
273
+ // is a symlink or resolves outside the repo root. Already-removed (missing)
274
+ // paths pass validation so removal stays idempotent.
275
+ try {
276
+ validateWorktreeRemovalPath(params.path, params.repoRoot);
277
+ }
278
+ catch (e) {
279
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, e.message);
280
+ }
281
+ // Trigger the WorktreeRemove hook (before git removal, non-blocking) using
282
+ // the session that runs in this worktree, if it is still registered.
283
+ for (const entry of this.sessions.values()) {
284
+ if (entry.agent.workingDirectory === params.path) {
285
+ try {
286
+ await entry.agent.triggerWorktreeRemoveHook(params.path);
287
+ }
288
+ catch (e) {
289
+ logger.warn("WorktreeRemove hooks execution failed:", e);
290
+ }
291
+ break;
292
+ }
293
+ }
271
294
  // removeWorktree is best-effort/idempotent: already-removed worktrees or
272
295
  // branches only log, never throw.
273
296
  await removeWorktree({
@@ -381,7 +404,7 @@ export class AgentBridge {
381
404
  const entry = this.requireSession(sessionId);
382
405
  const { messages } = await entry.agent.getFullMessageThread();
383
406
  const checkpoints = messages
384
- .filter((m) => m.role === "user" && !m.isMeta && m.id)
407
+ .filter((m) => isUserCheckpointMessage(m) && m.id)
385
408
  .map((m) => ({
386
409
  id: m.id,
387
410
  content: getMessageContent(m).replace(/\s+/g, " ").trim(),
@@ -0,0 +1,8 @@
1
+ import type { Message } from "wave-agent-sdk";
2
+ /**
3
+ * 判断一条 user 消息能否作为 /rewind 检查点。
4
+ * 后台任务通知(task_notification)与 hook 注入的消息(source: "hook")
5
+ * 都是系统生成、用户不可见的,不能作为回滚点。
6
+ * CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
7
+ */
8
+ export declare function isUserCheckpointMessage(m: Message): boolean;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 判断一条 user 消息能否作为 /rewind 检查点。
3
+ * 后台任务通知(task_notification)与 hook 注入的消息(source: "hook")
4
+ * 都是系统生成、用户不可见的,不能作为回滚点。
5
+ * CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
6
+ */
7
+ export function isUserCheckpointMessage(m) {
8
+ if (m.role !== "user" || m.isMeta)
9
+ return false;
10
+ if (m.blocks.some((b) => b.type === "task_notification"))
11
+ return false;
12
+ if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
13
+ return false;
14
+ return true;
15
+ }
@@ -7,6 +7,14 @@ export interface WorktreeSession {
7
7
  hasNewCommits: boolean;
8
8
  isNew: boolean;
9
9
  }
10
+ /**
11
+ * Validate a worktree name before any side effects. Names are slash-separated
12
+ * slugs: every segment must be non-empty and contain only letters, digits,
13
+ * dots, underscores, and dashes. The total length is capped at 64 characters
14
+ * and "." / ".." segments are rejected (path traversal protection).
15
+ * @throws {Error} When the name is not a valid slug
16
+ */
17
+ export declare function validateWorktreeSlug(name: string): void;
10
18
  /**
11
19
  * Create a new git worktree
12
20
  * @param name Worktree name
@@ -2,11 +2,34 @@ 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 { getDefaultRemoteBranch, getGitMainRepoRoot, performPostCreationSetup, } from "wave-agent-sdk";
6
6
  // Never use execFileSync here: the shared `wave --stdio` process handles all
7
7
  // desktop sessions, so a synchronous git call (especially a multi-second
8
8
  // recursive worktree delete or a network fetch) freezes every session.
9
9
  const execFileAsync = promisify(execFile);
10
+ // --- Worktree name validation ------------------------------------------------
11
+ const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
12
+ const MAX_WORKTREE_SLUG_LENGTH = 64;
13
+ /**
14
+ * Validate a worktree name before any side effects. Names are slash-separated
15
+ * slugs: every segment must be non-empty and contain only letters, digits,
16
+ * dots, underscores, and dashes. The total length is capped at 64 characters
17
+ * and "." / ".." segments are rejected (path traversal protection).
18
+ * @throws {Error} When the name is not a valid slug
19
+ */
20
+ export function validateWorktreeSlug(name) {
21
+ if (name.length > MAX_WORKTREE_SLUG_LENGTH) {
22
+ throw new Error(`Invalid worktree name: "${name}" must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${name.length})`);
23
+ }
24
+ for (const segment of name.split("/")) {
25
+ if (segment === "." || segment === "..") {
26
+ throw new Error(`Invalid worktree name: "${name}" must not contain "." or ".." path segments`);
27
+ }
28
+ if (segment.length === 0 || !VALID_WORKTREE_SLUG_SEGMENT.test(segment)) {
29
+ throw new Error(`Invalid worktree name: "${name}" each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`);
30
+ }
31
+ }
32
+ }
10
33
  /**
11
34
  * Create a new git worktree
12
35
  * @param name Worktree name
@@ -17,6 +40,14 @@ const execFileAsync = promisify(execFile);
17
40
  * @returns Worktree session details
18
41
  */
19
42
  export async function createWorktree(name, cwd, options) {
43
+ validateWorktreeSlug(name);
44
+ const session = await createWorktreeInternal(name, cwd, options);
45
+ if (session.isNew) {
46
+ await performPostCreationSetup(session.path, session.repoRoot);
47
+ }
48
+ return session;
49
+ }
50
+ async function createWorktreeInternal(name, cwd, options) {
20
51
  const repoRoot = getGitMainRepoRoot(cwd);
21
52
  const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
22
53
  const branchName = `worktree-${name}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "0.19.9",
3
+ "version": "1.0.0",
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.9"
44
+ "wave-agent-sdk": "1.0.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/react": "^19.1.8",
@@ -100,6 +100,8 @@ export const ChatInterface: React.FC = () => {
100
100
  <InputBox
101
101
  isLoading={isLoading}
102
102
  isCommandRunning={isCommandRunning}
103
+ isCompacting={isCompacting}
104
+ isGoalEvaluating={isGoalEvaluating}
103
105
  sendMessage={sendMessage}
104
106
  abortMessage={abortMessage}
105
107
  mcpServers={mcpServers}
@@ -74,11 +74,17 @@ export const HelpView: React.FC<HelpViewProps> = ({
74
74
  { key: "Ctrl+B", description: "Background current task" },
75
75
  { key: "Ctrl+V", description: "Paste image" },
76
76
  { key: "Ctrl+J", description: "Newline" },
77
+ { key: "Ctrl+A", description: "Cursor to line start" },
78
+ { key: "Ctrl+E", description: "Cursor to line end" },
79
+ { key: "Ctrl+U", description: "Delete to line start" },
80
+ { key: "Ctrl+K", description: "Delete to line end" },
81
+ { key: "Ctrl+W", description: "Delete word before cursor" },
77
82
  { key: "Shift+Tab", description: "Cycle permission mode" },
78
83
  {
79
84
  key: "Esc",
80
85
  description: "Interrupt AI or command / Cancel selector / Close help",
81
86
  },
87
+ { key: "Esc ×2", description: "Clear input (idle)" },
82
88
  ];
83
89
 
84
90
  const footerText = [
@@ -31,6 +31,8 @@ export const INPUT_PLACEHOLDER_TEXT_PREFIX = INPUT_PLACEHOLDER_TEXT.substring(
31
31
  export interface InputBoxProps {
32
32
  isLoading?: boolean;
33
33
  isCommandRunning?: boolean;
34
+ isCompacting?: boolean;
35
+ isGoalEvaluating?: boolean;
34
36
  workdir?: string;
35
37
  sendMessage?: (
36
38
  message: string,
@@ -54,6 +56,10 @@ export interface InputBoxProps {
54
56
  }
55
57
 
56
58
  export const InputBox: React.FC<InputBoxProps> = ({
59
+ isLoading,
60
+ isCommandRunning,
61
+ isCompacting,
62
+ isGoalEvaluating,
57
63
  sendMessage = () => {},
58
64
  abortMessage = () => {},
59
65
  mcpServers = [],
@@ -92,6 +98,15 @@ export const InputBox: React.FC<InputBoxProps> = ({
92
98
 
93
99
  const hasQueuedMessages = (queuedMessages?.length ?? 0) > 0;
94
100
 
101
+ // Idle means no AI work in flight. Esc double-press clear only applies when
102
+ // idle; while busy, Esc keeps its abort semantics.
103
+ const isIdle = !(
104
+ isLoading ||
105
+ isCommandRunning ||
106
+ isCompacting ||
107
+ isGoalEvaluating
108
+ );
109
+
95
110
  const onRecallQueuedMessage = useCallback(() => {
96
111
  const msg = recallQueuedMessage();
97
112
  if (msg) {
@@ -146,6 +161,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
146
161
  setPermissionMode,
147
162
  // BTW state
148
163
  btwState,
164
+ // Esc double-press clear pending
165
+ escClearPending,
149
166
  // Main handler
150
167
  handleInput,
151
168
  // Manager ready state
@@ -166,6 +183,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
166
183
  workdir: workingDirectory,
167
184
  getFullMessageThread,
168
185
  hasQueuedMessages,
186
+ isIdle,
169
187
  onRecallQueuedMessage,
170
188
  });
171
189
 
@@ -342,6 +360,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
342
360
  showPluginManager ||
343
361
  showWorkflowManager || (
344
362
  <Box flexDirection="column">
363
+ {escClearPending && <Text color="gray">再次按 Esc 清空输入</Text>}
345
364
  <Box
346
365
  borderStyle="single"
347
366
  borderColor="gray"
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink";
3
3
  import type { Message } from "wave-agent-sdk";
4
4
  import { getMessageContent } from "wave-agent-sdk";
5
5
  import { rewindSelectorReducer } from "../reducers/rewindSelectorReducer.js";
6
+ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
6
7
 
7
8
  export interface RewindCommandProps {
8
9
  messages: Message[];
@@ -32,10 +33,11 @@ export const RewindCommand: React.FC<RewindCommandProps> = ({
32
33
  }
33
34
  }, [getFullMessageThread]);
34
35
 
35
- // Filter user messages as checkpoints, excluding meta messages
36
+ // Filter user messages as checkpoints, excluding meta messages and
37
+ // system-generated user-role messages (task notifications, hook injections)
36
38
  const checkpoints = messages
37
39
  .map((msg, index) => ({ msg, index }))
38
- .filter(({ msg }) => msg.role === "user" && !msg.isMeta);
40
+ .filter(({ msg }) => isUserCheckpointMessage(msg));
39
41
 
40
42
  const MAX_VISIBLE_ITEMS = 3;
41
43
 
@@ -4,6 +4,7 @@ import {
4
4
  inputReducer,
5
5
  initialState,
6
6
  InputManagerCallbacks,
7
+ ESC_DOUBLE_PRESS_TIMEOUT_MS,
7
8
  } from "../managers/inputReducer.js";
8
9
  import {
9
10
  searchFiles as searchFilesUtil,
@@ -48,6 +49,7 @@ export const useInputManager = (
48
49
  onClearMessages,
49
50
  onCompact,
50
51
  onGoalCommand,
52
+ isIdle: isIdleProp,
51
53
  } = callbacks;
52
54
 
53
55
  // Handle debounced file search
@@ -70,25 +72,15 @@ export const useInputManager = (
70
72
  }
71
73
  }, [state.showFileSelector, state.fileSearchQuery]);
72
74
 
73
- // Handle paste debouncing
75
+ // Auto-expire the double-Esc clear pending flag after the timeout window
76
+ // (aligned with Claude Code's useDoublePress timeout-based expiry).
74
77
  useEffect(() => {
75
- if (state.isPasting) {
76
- const pasteDebounceDelay = parseInt(
77
- process.env.PASTE_DEBOUNCE_MS || "30",
78
- 10,
79
- );
80
- const timer = setTimeout(() => {
81
- const processedInput = state.pasteBuffer.replace(/\r/g, "\n");
82
- dispatch({
83
- type: "INSERT_TEXT_WITH_PLACEHOLDER",
84
- payload: processedInput,
85
- });
86
- dispatch({ type: "END_PASTE" });
87
- dispatch({ type: "RESET_HISTORY_NAVIGATION" });
88
- }, pasteDebounceDelay);
89
- return () => clearTimeout(timer);
90
- }
91
- }, [state.isPasting, state.pasteBuffer]);
78
+ if (!state.escClearPending) return;
79
+ const timer = setTimeout(() => {
80
+ dispatch({ type: "RESET_ESC_CLEAR_PENDING" });
81
+ }, ESC_DOUBLE_PRESS_TIMEOUT_MS);
82
+ return () => clearTimeout(timer);
83
+ }, [state.escClearPending]);
92
84
 
93
85
  // Sync state changes with callbacks
94
86
  useEffect(() => {
@@ -127,6 +119,16 @@ export const useInputManager = (
127
119
  case "ABORT_MESSAGE":
128
120
  onAbortMessage?.();
129
121
  break;
122
+ case "SAVE_PROMPT_HISTORY":
123
+ PromptHistoryManager.addEntry(
124
+ effect.content,
125
+ sessionId,
126
+ effect.longTextMap,
127
+ workdir,
128
+ ).catch((err: unknown) => {
129
+ logger?.error("Failed to save prompt history", err);
130
+ });
131
+ break;
130
132
  case "BACKGROUND_CURRENT_TASK":
131
133
  onBackgroundCurrentTask?.();
132
134
  break;
@@ -501,10 +503,11 @@ export const useInputManager = (
501
503
  key: {} as Key,
502
504
  hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
503
505
  hasQueuedMessages: hasQueuedMessagesProp ?? false,
506
+ isIdle: isIdleProp ?? false,
504
507
  },
505
508
  });
506
509
  },
507
- [onHasSlashCommand, hasQueuedMessagesProp],
510
+ [onHasSlashCommand, hasQueuedMessagesProp, isIdleProp],
508
511
  );
509
512
 
510
513
  const handleSubmit = useCallback(async () => {
@@ -515,9 +518,10 @@ export const useInputManager = (
515
518
  key: { return: true } as Key,
516
519
  hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
517
520
  hasQueuedMessages: hasQueuedMessagesProp ?? false,
521
+ isIdle: isIdleProp ?? false,
518
522
  },
519
523
  });
520
- }, [onHasSlashCommand, hasQueuedMessagesProp]);
524
+ }, [onHasSlashCommand, hasQueuedMessagesProp, isIdleProp]);
521
525
 
522
526
  const expandLongTextPlaceholders = useCallback(
523
527
  (text: string) => {
@@ -539,11 +543,12 @@ export const useInputManager = (
539
543
  key,
540
544
  hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
541
545
  hasQueuedMessages: hasQueuedMessagesProp ?? false,
546
+ isIdle: isIdleProp ?? false,
542
547
  },
543
548
  });
544
549
  return true;
545
550
  },
546
- [onHasSlashCommand, hasQueuedMessagesProp],
551
+ [onHasSlashCommand, hasQueuedMessagesProp, isIdleProp],
547
552
  );
548
553
 
549
554
  return {
@@ -572,6 +577,7 @@ export const useInputManager = (
572
577
  permissionMode: state.permissionMode,
573
578
  attachedImages: state.attachedImages,
574
579
  btwState: state.btwState,
580
+ escClearPending: state.escClearPending,
575
581
  isManagerReady: true,
576
582
 
577
583
  // Methods
@@ -292,17 +292,13 @@ export const handlePasteInput = (
292
292
  input: string,
293
293
  ): void => {
294
294
  const inputString = input;
295
- const isPasteOperation =
296
- inputString.length > 1 ||
297
- inputString.includes("\n") ||
298
- inputString.includes("\r");
299
-
300
- if (isPasteOperation) {
301
- // Dispatch a single action type; the reducer determines start vs append
302
- // by checking pasteBuffer, avoiding stale state issues
295
+
296
+ if (inputString.length > 1) {
297
+ // Multi-char chunk: insert immediately (\r → \n normalizes CRLF
298
+ // terminals), matching the reducer's HANDLE_KEY path.
303
299
  dispatch({
304
- type: "APPEND_PASTE_CHUNK",
305
- payload: { chunk: inputString, cursorPosition: state.cursorPosition },
300
+ type: "INSERT_TEXT_WITH_PLACEHOLDER",
301
+ payload: inputString.replace(/\r/g, "\n"),
306
302
  });
307
303
  } else {
308
304
  let char = inputString;