wave-code 0.19.7 → 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.
@@ -1,18 +1,27 @@
1
- import { execFileSync } from "node:child_process";
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
14
+ * @param options Optional creation options
15
+ * @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
16
+ * @param options.baseBranch Explicit base branch (overrides baseRef)
9
17
  * @returns Worktree session details
10
18
  */
11
- export function createWorktree(name, cwd) {
19
+ export async function createWorktree(name, cwd, options) {
12
20
  const repoRoot = getGitMainRepoRoot(cwd);
13
21
  const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
14
22
  const branchName = `worktree-${name}`;
15
- const baseBranch = getDefaultRemoteBranch(cwd);
23
+ const useHead = options?.baseRef === "head";
24
+ const resolvedBaseBranch = options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
16
25
  // Ensure parent directory exists
17
26
  const parentDir = path.dirname(worktreePath);
18
27
  if (!fs.existsSync(parentDir)) {
@@ -33,9 +42,8 @@ export function createWorktree(name, cwd) {
33
42
  }
34
43
  try {
35
44
  // Create worktree and branch
36
- execFileSync("git", ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
45
+ await execFileAsync("git", ["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch], {
37
46
  cwd: repoRoot,
38
- stdio: ["ignore", "pipe", "pipe"],
39
47
  });
40
48
  return {
41
49
  name,
@@ -52,9 +60,8 @@ export function createWorktree(name, cwd) {
52
60
  if (stderr.includes("already exists")) {
53
61
  // If branch already exists, try to add worktree without -b
54
62
  try {
55
- execFileSync("git", ["worktree", "add", worktreePath, branchName], {
63
+ await execFileAsync("git", ["worktree", "add", worktreePath, branchName], {
56
64
  cwd: repoRoot,
57
- stdio: ["ignore", "pipe", "pipe"],
58
65
  });
59
66
  return {
60
67
  name,
@@ -70,18 +77,24 @@ export function createWorktree(name, cwd) {
70
77
  throw new Error(`Failed to add existing worktree branch: ${innerError.message}`);
71
78
  }
72
79
  }
73
- if (stderr.includes("not a valid object name") ||
74
- stderr.includes("unknown revision")) {
80
+ if (!useHead &&
81
+ (stderr.includes("not a valid object name") ||
82
+ stderr.includes("unknown revision"))) {
75
83
  // Base branch not fetched yet — try fetching then retrying
76
- const branchNameOnly = baseBranch.split("/").pop();
84
+ const branchNameOnly = resolvedBaseBranch.split("/").pop();
77
85
  try {
78
- execFileSync("git", ["fetch", "origin", branchNameOnly], {
86
+ await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
79
87
  cwd: repoRoot,
80
- stdio: ["ignore", "pipe", "pipe"],
81
88
  });
82
- execFileSync("git", ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
89
+ await execFileAsync("git", [
90
+ "worktree",
91
+ "add",
92
+ "-b",
93
+ branchName,
94
+ worktreePath,
95
+ resolvedBaseBranch,
96
+ ], {
83
97
  cwd: repoRoot,
84
- stdio: ["ignore", "pipe", "pipe"],
85
98
  });
86
99
  return {
87
100
  name,
@@ -96,9 +109,8 @@ export function createWorktree(name, cwd) {
96
109
  catch {
97
110
  // Fetch or retry failed — fall back to HEAD
98
111
  try {
99
- execFileSync("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], {
112
+ await execFileAsync("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], {
100
113
  cwd: repoRoot,
101
- stdio: ["ignore", "pipe", "pipe"],
102
114
  });
103
115
  return {
104
116
  name,
@@ -122,31 +134,29 @@ export function createWorktree(name, cwd) {
122
134
  * Remove a git worktree and its associated branch
123
135
  * @param session Worktree session details
124
136
  */
125
- export function removeWorktree(session) {
137
+ export async function removeWorktree(session) {
126
138
  const repoRoot = session.repoRoot;
127
139
  try {
128
140
  // Get current branch in worktree before removing it
129
141
  let currentBranch;
130
142
  try {
131
- currentBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
143
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
132
144
  cwd: session.path,
133
145
  encoding: "utf8",
134
- stdio: ["ignore", "pipe", "ignore"],
135
- }).trim();
146
+ });
147
+ currentBranch = stdout.trim();
136
148
  }
137
149
  catch {
138
150
  // Ignore errors getting current branch
139
151
  }
140
152
  // Remove worktree
141
- execFileSync("git", ["worktree", "remove", "--force", session.path], {
153
+ await execFileAsync("git", ["worktree", "remove", "--force", session.path], {
142
154
  cwd: repoRoot,
143
- stdio: ["ignore", "pipe", "pipe"],
144
155
  });
145
156
  // Delete original branch
146
157
  try {
147
- execFileSync("git", ["branch", "-D", session.branch], {
158
+ await execFileAsync("git", ["branch", "-D", session.branch], {
148
159
  cwd: repoRoot,
149
- stdio: ["ignore", "pipe", "pipe"],
150
160
  });
151
161
  }
152
162
  catch {
@@ -162,9 +172,8 @@ export function removeWorktree(session) {
162
172
  currentBranch !== "main" &&
163
173
  currentBranch !== "master") {
164
174
  try {
165
- execFileSync("git", ["branch", "-D", currentBranch], {
175
+ await execFileAsync("git", ["branch", "-D", currentBranch], {
166
176
  cwd: repoRoot,
167
- stdio: ["ignore", "pipe", "pipe"],
168
177
  });
169
178
  }
170
179
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "0.19.7",
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.7"
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);
@@ -39,7 +39,7 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
39
39
  onCancel,
40
40
  }) => {
41
41
  const [state, dispatch] = useReducer(confirmationReducer, {
42
- selectedOption: toolName === EXIT_PLAN_MODE_TOOL_NAME ? "clear" : "allow",
42
+ selectedOption: "allow",
43
43
  alternativeText: "",
44
44
  alternativeCursorPosition: 0,
45
45
  hasUserInput: false,
@@ -212,20 +212,6 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
212
212
  <Text>Do you want to proceed?</Text>
213
213
  </Box>
214
214
  <Box marginTop={1} flexDirection="column">
215
- {toolName === EXIT_PLAN_MODE_TOOL_NAME && (
216
- <Box key="clear-option">
217
- <Text
218
- color={state.selectedOption === "clear" ? "black" : "white"}
219
- backgroundColor={
220
- state.selectedOption === "clear" ? "yellow" : undefined
221
- }
222
- bold={state.selectedOption === "clear"}
223
- >
224
- {state.selectedOption === "clear" ? "> " : " "}
225
- Yes, clear context and auto-accept edits
226
- </Text>
227
- </Box>
228
- )}
229
215
  <Box key="allow-option">
230
216
  <Text
231
217
  color={state.selectedOption === "allow" ? "black" : "white"}
@@ -254,6 +240,21 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
254
240
  </Text>
255
241
  </Box>
256
242
  )}
243
+ {(toolName === BASH_TOOL_NAME ||
244
+ toolName === EXIT_PLAN_MODE_TOOL_NAME) && (
245
+ <Box key="bypass-option">
246
+ <Text
247
+ color={state.selectedOption === "bypass" ? "black" : "white"}
248
+ backgroundColor={
249
+ state.selectedOption === "bypass" ? "yellow" : undefined
250
+ }
251
+ bold={state.selectedOption === "bypass"}
252
+ >
253
+ {state.selectedOption === "bypass" ? "> " : " "}
254
+ Yes, and bypass permissions
255
+ </Text>
256
+ </Box>
257
+ )}
257
258
  <Box key="alternative-option">
258
259
  <Text
259
260
  color={
@@ -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
- // If all tasks are already completed on mount (e.g. session restore),
104
- // start hidden immediately instead of flashing for 5 seconds.
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 (allCompleted && !autoHidden) {
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 (tasks.length === 0 || !isTaskListVisible || autoHidden) {
188
+ if (
189
+ tasks.length === 0 ||
190
+ !isTaskListVisible ||
191
+ autoHidden ||
192
+ (allCompleted && !hadIncompleteRef.current)
193
+ ) {
176
194
  return null;
177
195
  }
178
196
 
@@ -482,7 +482,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
482
482
 
483
483
  // Inject worktree session into this agent's own DI container so system
484
484
  // prompts and permission checks reflect the CLI -w worktree. This state is
485
- // per-session, not process-global (see specs/047-worktree.md FR-042).
485
+ // per-session, not process-global (see docs/specs/multi-agent/worktree.md FR-042).
486
486
  if (worktreeSession) {
487
487
  const session: WorktreeSession = {
488
488
  originalCwd: originalCwd ?? worktreeSession.repoRoot,
package/src/index.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import yargs from "yargs";
2
2
  import { hideBin } from "yargs/helpers";
3
3
  import { startCli } from "./cli.js";
4
- import { Scope, generateRandomName, type PermissionMode } from "wave-agent-sdk";
4
+ import {
5
+ Scope,
6
+ generateRandomName,
7
+ loadMergedWaveConfig,
8
+ type PermissionMode,
9
+ } from "wave-agent-sdk";
5
10
  import { createWorktree, type WorktreeSession } from "./utils/worktree.js";
6
11
  import path from "path";
7
12
  import { readFileSync } from "fs";
@@ -320,7 +325,8 @@ export async function main() {
320
325
  if (!name || name === "") {
321
326
  name = generateRandomName();
322
327
  }
323
- worktreeSession = createWorktree(name, originalCwd);
328
+ const baseRef = loadMergedWaveConfig(originalCwd)?.worktree?.baseRef;
329
+ worktreeSession = await createWorktree(name, originalCwd, { baseRef });
324
330
 
325
331
  // Note: the full worktree session (originalCwd etc.) is injected into the
326
332
  // agent's DI container after the agent is created in useChat.tsx. This keeps
package/src/print-cli.ts CHANGED
@@ -154,8 +154,18 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
154
154
  process.stdout.write("\n");
155
155
  }
156
156
 
157
- // Wait for running background tasks and subagents to complete
158
- while (agent.hasRunningBackgroundWork) {
157
+ // Wait for running background tasks/subagents to complete AND for the main
158
+ // agent to finish processing their completion notifications. The last
159
+ // background task flips status to "completed" before its notification is
160
+ // enqueued and before the main agent's follow-up turn runs, so
161
+ // hasRunningBackgroundWork alone becomes false while the main agent is
162
+ // still mid-turn — causing a TOCTOU race that aborts the final response.
163
+ // Including isLoading and queued notifications closes that gap.
164
+ while (
165
+ agent.hasRunningBackgroundWork ||
166
+ agent.isLoading ||
167
+ agent.hasPendingMessages
168
+ ) {
159
169
  await new Promise((resolve) => setTimeout(resolve, 500));
160
170
  }
161
171
 
@@ -184,7 +194,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
184
194
  const hasCommits = hasNewCommits(cwd, baseBranch);
185
195
 
186
196
  if (!hasChanges && !hasCommits) {
187
- removeWorktree(worktreeSession);
197
+ await removeWorktree(worktreeSession);
188
198
  } else {
189
199
  process.stdout.write(
190
200
  `\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
@@ -220,7 +230,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
220
230
  const hasCommits = hasNewCommits(cwd, baseBranch);
221
231
 
222
232
  if (!hasChanges && !hasCommits) {
223
- removeWorktree(worktreeSession);
233
+ await removeWorktree(worktreeSession);
224
234
  } else {
225
235
  process.stdout.write(
226
236
  `\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
@@ -7,7 +7,7 @@ import {
7
7
  } from "wave-agent-sdk";
8
8
 
9
9
  export interface ConfirmationState {
10
- selectedOption: "clear" | "auto" | "allow" | "alternative";
10
+ selectedOption: "auto" | "bypass" | "allow" | "alternative";
11
11
  alternativeText: string;
12
12
  alternativeCursorPosition: number;
13
13
  hasUserInput: boolean;
@@ -99,13 +99,7 @@ export function confirmationReducer(
99
99
 
100
100
  if (key.return) {
101
101
  let decision: PermissionDecision | null = null;
102
- if (state.selectedOption === "clear") {
103
- decision = {
104
- behavior: "allow",
105
- newPermissionMode: "acceptEdits",
106
- clearContext: true,
107
- };
108
- } else if (state.selectedOption === "allow") {
102
+ if (state.selectedOption === "allow") {
109
103
  if (toolName === EXIT_PLAN_MODE_TOOL_NAME) {
110
104
  decision = { behavior: "allow", newPermissionMode: "default" };
111
105
  } else if (toolName === ENTER_PLAN_MODE_TOOL_NAME) {
@@ -134,6 +128,11 @@ export function confirmationReducer(
134
128
  } else {
135
129
  decision = { behavior: "allow", newPermissionMode: "acceptEdits" };
136
130
  }
131
+ } else if (state.selectedOption === "bypass") {
132
+ decision = {
133
+ behavior: "allow",
134
+ newPermissionMode: "bypassPermissions",
135
+ };
137
136
  } else if (state.alternativeText.trim()) {
138
137
  decision = {
139
138
  behavior: "deny",
@@ -174,9 +173,10 @@ export function confirmationReducer(
174
173
  }
175
174
 
176
175
  const availableOptions: ConfirmationState["selectedOption"][] = [];
177
- if (toolName === EXIT_PLAN_MODE_TOOL_NAME) availableOptions.push("clear");
178
176
  availableOptions.push("allow");
179
177
  if (!hidePersistentOption) availableOptions.push("auto");
178
+ if (toolName === BASH_TOOL_NAME || toolName === EXIT_PLAN_MODE_TOOL_NAME)
179
+ availableOptions.push("bypass");
180
180
  availableOptions.push("alternative");
181
181
 
182
182
  if (key.upArrow) {