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.
@@ -19,6 +19,9 @@ import {
19
19
  Agent,
20
20
  type AgentCallbacks,
21
21
  type AgentOptions,
22
+ type BackgroundTask,
23
+ type BackgroundTaskSummary,
24
+ type SerializableWorkflowRun,
22
25
  type Message,
23
26
  type PermissionDecision,
24
27
  type PermissionMode,
@@ -31,6 +34,9 @@ import {
31
34
  type Scope,
32
35
  listSessions,
33
36
  searchFiles,
37
+ generateRandomName,
38
+ getDefaultRemoteBranch,
39
+ getMessageContent,
34
40
  PromptHistoryManager,
35
41
  AuthService,
36
42
  PluginCore,
@@ -41,6 +47,8 @@ import {
41
47
  INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
42
48
  METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
43
49
  } from "./protocol.js";
50
+ import { execFileSync } from "node:child_process";
51
+ import { createWorktree, removeWorktree } from "../utils/worktree.js";
44
52
  import { logger } from "../utils/logger.js";
45
53
 
46
54
  export type NotificationEmitter = (
@@ -69,6 +77,8 @@ interface InitializeParams {
69
77
  disallowedTools?: string[];
70
78
  pluginDirs?: string[];
71
79
  mcpServers?: Record<string, McpServerConfig>;
80
+ worktreeName?: string;
81
+ isNewWorktree?: boolean;
72
82
  }
73
83
 
74
84
  interface UpdateConfigParams {
@@ -160,6 +170,8 @@ export class AgentBridge {
160
170
  return this.clearMessages(sessionId);
161
171
  case "rewindToMessage":
162
172
  return this.rewindToMessage(p.messageId as string, sessionId);
173
+ case "listRewindCheckpoints":
174
+ return this.listRewindCheckpoints(sessionId);
163
175
  case "deleteQueuedMessage":
164
176
  return this.deleteQueuedMessage(p.index as number, sessionId);
165
177
  case "updateQueuedMessage":
@@ -247,6 +259,19 @@ export class AgentBridge {
247
259
  p.workdir as string | undefined,
248
260
  sessionId,
249
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
+ );
250
275
  case "updatePlugin":
251
276
  return this.updatePlugin(
252
277
  p.pluginId as string,
@@ -278,6 +303,38 @@ export class AgentBridge {
278
303
  p.workdir as string | undefined,
279
304
  sessionId,
280
305
  );
306
+ case "compact":
307
+ return this.compact(
308
+ p.customInstructions as string | undefined,
309
+ sessionId,
310
+ );
311
+
312
+ // ── Background tasks ──
313
+ case "getBackgroundTaskOutput":
314
+ return this.getBackgroundTaskOutput(p.taskId as string, sessionId);
315
+ case "stopBackgroundTask":
316
+ return this.stopBackgroundTask(p.taskId as string, sessionId);
317
+
318
+ case "getWorkflowRuns":
319
+ return this.getWorkflowRuns(sessionId);
320
+ case "stopWorkflowRun":
321
+ return this.stopWorkflowRun(p.runId as string, sessionId);
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
+ );
281
338
 
282
339
  default:
283
340
  throw new RpcError(
@@ -330,6 +387,8 @@ export class AgentBridge {
330
387
  disallowedTools: params.disallowedTools,
331
388
  plugins: params.pluginDirs?.map((path) => ({ type: "local", path })),
332
389
  mcpServers: params.mcpServers,
390
+ worktreeName: params.worktreeName,
391
+ isNewWorktree: params.isNewWorktree,
333
392
  canUseTool: (context: ToolPermissionContext) =>
334
393
  this.canUseTool(context, ctx),
335
394
  };
@@ -381,6 +440,106 @@ export class AgentBridge {
381
440
  return { sessions };
382
441
  }
383
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
+
384
543
  private getSessionInfo(sessionId?: string): {
385
544
  sessionId: string;
386
545
  workingDirectory: string;
@@ -431,6 +590,9 @@ export class AgentBridge {
431
590
  path: p,
432
591
  })),
433
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,
434
596
  canUseTool: (context: ToolPermissionContext) =>
435
597
  this.canUseTool(context, ctx),
436
598
  };
@@ -516,6 +678,20 @@ export class AgentBridge {
516
678
  return { inputContent: textBlock?.content || "" };
517
679
  }
518
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
+
519
695
  private deleteQueuedMessage(index: number, sessionId?: string): null {
520
696
  const entry = this.requireSession(sessionId);
521
697
  entry.agent.removeQueuedMessage(index);
@@ -555,6 +731,57 @@ export class AgentBridge {
555
731
  return entry.agent.getFullMessageThread();
556
732
  }
557
733
 
734
+ private async compact(
735
+ customInstructions: string | undefined,
736
+ sessionId?: string,
737
+ ): Promise<null> {
738
+ const entry = this.requireSession(sessionId);
739
+ await entry.agent.compact(customInstructions);
740
+ return null;
741
+ }
742
+
743
+ // ── Background tasks ──
744
+
745
+ private getBackgroundTaskOutput(
746
+ taskId: string,
747
+ sessionId?: string,
748
+ ): { output: ReturnType<Agent["getBackgroundTaskOutput"]> } {
749
+ const entry = this.requireSession(sessionId);
750
+ return { output: entry.agent.getBackgroundTaskOutput(taskId) };
751
+ }
752
+
753
+ private stopBackgroundTask(
754
+ taskId: string,
755
+ sessionId?: string,
756
+ ): { success: boolean } {
757
+ const entry = this.requireSession(sessionId);
758
+ const success = entry.agent.stopBackgroundTask(taskId);
759
+ return { success };
760
+ }
761
+
762
+ private async getWorkflowRuns(
763
+ sessionId?: string,
764
+ ): Promise<{ runs: SerializableWorkflowRun[] }> {
765
+ const entry = this.requireSession(sessionId);
766
+ const runs = await entry.agent.getWorkflowRuns();
767
+ return {
768
+ runs: runs.map((r) => {
769
+ const { completionPromise, ...rest } = r;
770
+ void completionPromise;
771
+ return rest;
772
+ }),
773
+ };
774
+ }
775
+
776
+ private stopWorkflowRun(
777
+ runId: string,
778
+ sessionId?: string,
779
+ ): { success: boolean } {
780
+ const entry = this.requireSession(sessionId);
781
+ entry.agent.stopWorkflowRun(runId);
782
+ return { success: true };
783
+ }
784
+
558
785
  // ── Permissions ───────────────────────────────────────────────
559
786
 
560
787
  private async setPermissionMode(
@@ -767,6 +994,35 @@ export class AgentBridge {
767
994
  );
768
995
  }
769
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
+
770
1026
  private async updatePlugin(
771
1027
  pluginId: string,
772
1028
  workdir?: string,
@@ -867,6 +1123,25 @@ export class AgentBridge {
867
1123
  onTasksChange: (tasks: Task[]) => {
868
1124
  this.emit("tasksChange", { tasks }, ctx.registeredSessionId);
869
1125
  },
1126
+ onBackgroundTasksChange: (tasks: BackgroundTask[]) => {
1127
+ const summaries: BackgroundTaskSummary[] = tasks.map((t) => ({
1128
+ id: t.id,
1129
+ type: t.type,
1130
+ status: t.status,
1131
+ startTime: t.startTime,
1132
+ endTime: t.endTime,
1133
+ command: t.command,
1134
+ description: t.description,
1135
+ exitCode: t.exitCode,
1136
+ runtime: t.runtime,
1137
+ outputPath: t.outputPath,
1138
+ }));
1139
+ this.emit(
1140
+ "backgroundTasksChange",
1141
+ { tasks: summaries },
1142
+ ctx.registeredSessionId,
1143
+ );
1144
+ },
870
1145
  onSessionIdChange: (newSessionId: string) => {
871
1146
  const oldSessionId = ctx.registeredSessionId;
872
1147
  // Emit with the OLD sessionId so the client's router can deliver it
@@ -922,6 +1197,13 @@ export class AgentBridge {
922
1197
  onCompactBlockAdded: (content: string) => {
923
1198
  this.emit("compactBlockAdded", { content }, ctx.registeredSessionId);
924
1199
  },
1200
+ onCompactionStateChange: (isCompacting: boolean) => {
1201
+ this.emit(
1202
+ "compactionStateChange",
1203
+ { isCompacting },
1204
+ ctx.registeredSessionId,
1205
+ );
1206
+ },
925
1207
  };
926
1208
  }
927
1209
 
@@ -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"
@@ -86,7 +87,16 @@ export type RequestMethod =
86
87
  | "listMarketplaces"
87
88
  | "addMarketplace"
88
89
  | "removeMarketplace"
89
- | "updateMarketplace";
90
+ | "updateMarketplace"
91
+ | "compact"
92
+ | "getBackgroundTaskOutput"
93
+ | "stopBackgroundTask"
94
+ | "getWorkflowRuns"
95
+ | "stopWorkflowRun"
96
+ // Git / worktree (global — no session required)
97
+ | "listGitBranches"
98
+ | "createWorktree"
99
+ | "removeWorktree";
90
100
 
91
101
  // ── Client → Server notification methods ────────────────────────
92
102
 
@@ -116,7 +126,9 @@ export type ServerNotificationMethod =
116
126
  | "notificationMessageAdded"
117
127
  | "permissionRequest"
118
128
  | "authUrl"
119
- | "compactBlockAdded";
129
+ | "compactBlockAdded"
130
+ | "compactionStateChange"
131
+ | "backgroundTasksChange";
120
132
 
121
133
  // ── Helper: is this a request (has id)? ─────────────────────────
122
134
 
@@ -1,8 +1,14 @@
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";
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;
@@ -17,13 +23,22 @@ export interface WorktreeSession {
17
23
  * Create a new git worktree
18
24
  * @param name Worktree name
19
25
  * @param cwd Current working directory
26
+ * @param options Optional creation options
27
+ * @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
28
+ * @param options.baseBranch Explicit base branch (overrides baseRef)
20
29
  * @returns Worktree session details
21
30
  */
22
- export function createWorktree(name: string, cwd: string): WorktreeSession {
31
+ export async function createWorktree(
32
+ name: string,
33
+ cwd: string,
34
+ options?: { baseRef?: "fresh" | "head"; baseBranch?: string },
35
+ ): Promise<WorktreeSession> {
23
36
  const repoRoot = getGitMainRepoRoot(cwd);
24
37
  const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
25
38
  const branchName = `worktree-${name}`;
26
- const baseBranch = getDefaultRemoteBranch(cwd);
39
+ const useHead = options?.baseRef === "head";
40
+ const resolvedBaseBranch =
41
+ options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
27
42
 
28
43
  // Ensure parent directory exists
29
44
  const parentDir = path.dirname(worktreePath);
@@ -47,12 +62,11 @@ export function createWorktree(name: string, cwd: string): WorktreeSession {
47
62
 
48
63
  try {
49
64
  // Create worktree and branch
50
- execFileSync(
65
+ await execFileAsync(
51
66
  "git",
52
- ["worktree", "add", "-b", branchName, worktreePath, baseBranch],
67
+ ["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch],
53
68
  {
54
69
  cwd: repoRoot,
55
- stdio: ["ignore", "pipe", "pipe"],
56
70
  },
57
71
  );
58
72
 
@@ -66,14 +80,18 @@ export function createWorktree(name: string, cwd: string): WorktreeSession {
66
80
  isNew: true,
67
81
  };
68
82
  } catch (error: unknown) {
69
- const stderr = (error as { stderr?: Buffer }).stderr?.toString() || "";
83
+ const stderr =
84
+ (error as { stderr?: Buffer | string }).stderr?.toString() || "";
70
85
  if (stderr.includes("already exists")) {
71
86
  // If branch already exists, try to add worktree without -b
72
87
  try {
73
- execFileSync("git", ["worktree", "add", worktreePath, branchName], {
74
- cwd: repoRoot,
75
- stdio: ["ignore", "pipe", "pipe"],
76
- });
88
+ await execFileAsync(
89
+ "git",
90
+ ["worktree", "add", worktreePath, branchName],
91
+ {
92
+ cwd: repoRoot,
93
+ },
94
+ );
77
95
  return {
78
96
  name,
79
97
  path: worktreePath,
@@ -90,22 +108,28 @@ export function createWorktree(name: string, cwd: string): WorktreeSession {
90
108
  }
91
109
  }
92
110
  if (
93
- stderr.includes("not a valid object name") ||
94
- stderr.includes("unknown revision")
111
+ !useHead &&
112
+ (stderr.includes("not a valid object name") ||
113
+ stderr.includes("unknown revision"))
95
114
  ) {
96
115
  // Base branch not fetched yet — try fetching then retrying
97
- const branchNameOnly = baseBranch.split("/").pop()!;
116
+ const branchNameOnly = resolvedBaseBranch.split("/").pop()!;
98
117
  try {
99
- execFileSync("git", ["fetch", "origin", branchNameOnly], {
118
+ await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
100
119
  cwd: repoRoot,
101
- stdio: ["ignore", "pipe", "pipe"],
102
120
  });
103
- execFileSync(
121
+ await execFileAsync(
104
122
  "git",
105
- ["worktree", "add", "-b", branchName, worktreePath, baseBranch],
123
+ [
124
+ "worktree",
125
+ "add",
126
+ "-b",
127
+ branchName,
128
+ worktreePath,
129
+ resolvedBaseBranch,
130
+ ],
106
131
  {
107
132
  cwd: repoRoot,
108
- stdio: ["ignore", "pipe", "pipe"],
109
133
  },
110
134
  );
111
135
  return {
@@ -120,12 +144,11 @@ export function createWorktree(name: string, cwd: string): WorktreeSession {
120
144
  } catch {
121
145
  // Fetch or retry failed — fall back to HEAD
122
146
  try {
123
- execFileSync(
147
+ await execFileAsync(
124
148
  "git",
125
149
  ["worktree", "add", "-b", branchName, worktreePath, "HEAD"],
126
150
  {
127
151
  cwd: repoRoot,
128
- stdio: ["ignore", "pipe", "pipe"],
129
152
  },
130
153
  );
131
154
  return {
@@ -154,37 +177,39 @@ export function createWorktree(name: string, cwd: string): WorktreeSession {
154
177
  * Remove a git worktree and its associated branch
155
178
  * @param session Worktree session details
156
179
  */
157
- export function removeWorktree(session: WorktreeSession): void {
180
+ export async function removeWorktree(session: WorktreeSession): Promise<void> {
158
181
  const repoRoot = session.repoRoot;
159
182
 
160
183
  try {
161
184
  // Get current branch in worktree before removing it
162
185
  let currentBranch: string | undefined;
163
186
  try {
164
- currentBranch = execFileSync(
187
+ const { stdout } = await execFileAsync(
165
188
  "git",
166
189
  ["rev-parse", "--abbrev-ref", "HEAD"],
167
190
  {
168
191
  cwd: session.path,
169
192
  encoding: "utf8",
170
- stdio: ["ignore", "pipe", "ignore"],
171
193
  },
172
- ).trim();
194
+ );
195
+ currentBranch = stdout.trim();
173
196
  } catch {
174
197
  // Ignore errors getting current branch
175
198
  }
176
199
 
177
200
  // Remove worktree
178
- execFileSync("git", ["worktree", "remove", "--force", session.path], {
179
- cwd: repoRoot,
180
- stdio: ["ignore", "pipe", "pipe"],
181
- });
201
+ await execFileAsync(
202
+ "git",
203
+ ["worktree", "remove", "--force", session.path],
204
+ {
205
+ cwd: repoRoot,
206
+ },
207
+ );
182
208
 
183
209
  // Delete original branch
184
210
  try {
185
- execFileSync("git", ["branch", "-D", session.branch], {
211
+ await execFileAsync("git", ["branch", "-D", session.branch], {
186
212
  cwd: repoRoot,
187
- stdio: ["ignore", "pipe", "pipe"],
188
213
  });
189
214
  } catch {
190
215
  // Ignore errors deleting original branch
@@ -205,9 +230,8 @@ export function removeWorktree(session: WorktreeSession): void {
205
230
  currentBranch !== "master"
206
231
  ) {
207
232
  try {
208
- execFileSync("git", ["branch", "-D", currentBranch], {
233
+ await execFileAsync("git", ["branch", "-D", currentBranch], {
209
234
  cwd: repoRoot,
210
- stdio: ["ignore", "pipe", "pipe"],
211
235
  });
212
236
  } catch {
213
237
  // Ignore errors deleting current branch