wave-code 1.1.4 → 1.2.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.
@@ -1,19 +1,17 @@
1
1
  /**
2
2
  * DaemonServer — JSON-RPC server over a unix socket for remote background
3
- * sessions (spec: docs/specs/ui/desktop-app.md 「SSH 远程后台会话」).
3
+ * sessions (spec: docs/specs/desktop/desktop-sessions.md 「SSH 远程后台会话」).
4
4
  *
5
5
  * The desktop app launches `wave --daemon <socket>` on the remote host via
6
6
  * nohup/setsid, then tunnels the socket back with `ssh -L`. All connections
7
7
  * share one AgentBridge, so sessions and pending tool permissions survive
8
- * client detach/attach the daemon keeps running (and generating) while no
9
- * desktop is connected. The daemon never exits on a client disconnect; it
10
- * only exits when killed (app quit / 删除会话 / remote reboot).
11
- *
12
- * Idle auto-exit (spec: 「远程 daemon 空闲自动退出」): once every session has
13
- * settled and no client is connected, the daemon mirrors `wave -p`'s exit
14
- * semantics — after a grace period it destroys the sessions (saving their
15
- * transcripts), closes the socket, and exits, so the remote process doesn't
16
- * linger forever after background work completes.
8
+ * client detach/attach. A launched daemon is resident: it keeps running (and
9
+ * generating) while no desktop is connected and never auto-exits once the
10
+ * work settles. It goes away when told to shut down gracefully (`wave daemon
11
+ * stop` / `restart`, destroying sessions so transcripts flush), when killed
12
+ * externally (desktop CLI 升级重启 pkill, remote reboot / machine reboot), and
13
+ * the next client that finds the socket absent starts a fresh daemon on
14
+ * demand.
17
15
  */
18
16
 
19
17
  import net from "net";
@@ -24,26 +22,17 @@ import { JsonRpcConnection } from "./jsonRpcConnection.js";
24
22
  export interface DaemonServerOptions {
25
23
  socketPath: string;
26
24
  bridgeOptions?: AgentBridgeOptions;
27
- /** Idle grace period before the daemon auto-exits (default 60s). */
28
- graceMs?: number;
29
25
  }
30
26
 
31
27
  export class DaemonServer {
32
- static readonly DEFAULT_IDLE_GRACE_MS = 60_000;
33
-
34
28
  private socketPath: string;
35
29
  private server: net.Server | undefined;
36
30
  private bridge: AgentBridge;
37
31
  private connections = new Set<JsonRpcConnection>();
38
32
  private sockets = new Set<net.Socket>();
39
- private graceMs: number;
40
- private idleTimer: NodeJS.Timeout | undefined;
41
- private shuttingDown = false;
42
- private stopped = false;
43
33
 
44
34
  constructor(options: DaemonServerOptions) {
45
35
  this.socketPath = options.socketPath;
46
- this.graceMs = options.graceMs ?? DaemonServer.DEFAULT_IDLE_GRACE_MS;
47
36
  this.bridge = new AgentBridge({
48
37
  ...options.bridgeOptions,
49
38
  // Notifications go to every attached client; a fully detached daemon
@@ -53,20 +42,15 @@ export class DaemonServer {
53
42
  for (const conn of this.connections) {
54
43
  conn.sendNotification(method, params, sessionId);
55
44
  }
56
- // Any session activity can change the idle state — re-evaluate.
57
- this.evaluateIdle();
58
45
  },
46
+ // The `shutdown` RPC (wave daemon stop/restart) destroys every session in
47
+ // the bridge, then hands off here to tear the process down.
48
+ onShutdownRequest: () => this.shutdown(),
59
49
  });
60
50
  this.server = net.createServer((socket) => {
61
- if (this.shuttingDown) {
62
- socket.destroy();
63
- return;
64
- }
65
51
  const conn = new JsonRpcConnection(socket, socket, this.bridge);
66
52
  this.connections.add(conn);
67
53
  this.sockets.add(socket);
68
- // A (re)attached client cancels a pending idle exit.
69
- this.evaluateIdle();
70
54
  socket.on("error", () => {
71
55
  // The client (ssh tunnel) can reset the socket mid-detach; the daemon
72
56
  // must keep running — 'close' below cleans up the connection.
@@ -74,8 +58,6 @@ export class DaemonServer {
74
58
  socket.on("close", () => {
75
59
  this.connections.delete(conn);
76
60
  this.sockets.delete(socket);
77
- // A client detach may leave the daemon idle — re-evaluate.
78
- this.evaluateIdle();
79
61
  });
80
62
  conn.start();
81
63
  });
@@ -137,15 +119,10 @@ export class DaemonServer {
137
119
  server.listen(this.socketPath, () => {
138
120
  server.removeListener("error", reject);
139
121
  resolve();
140
- // A freshly started daemon may already be idle (no sessions) — start the
141
- // idle watch so a zero-session daemon also auto-exits.
142
- this.evaluateIdle();
143
122
  });
144
123
  }
145
124
 
146
125
  stop(): Promise<void> {
147
- this.stopped = true;
148
- this.clearIdleTimer();
149
126
  return new Promise((resolve) => {
150
127
  const server = this.server;
151
128
  if (!server) return resolve();
@@ -154,61 +131,28 @@ export class DaemonServer {
154
131
  });
155
132
  }
156
133
 
157
- // ── Idle auto-exit ────────────────────────────────────────────
158
-
159
- private clearIdleTimer(): void {
160
- if (this.idleTimer) {
161
- clearTimeout(this.idleTimer);
162
- this.idleTimer = undefined;
163
- }
164
- }
165
-
166
134
  /**
167
- * Re-evaluate the idle condition after any state transition: sessions busy
168
- * (loading / pending messages / background work) or any client attached →
169
- * cancel the timer. Fully idle + detached arm the grace timer once; when
170
- * it fires, shut the daemon down. Evaluation is event-driven (every
171
- * busy→idle transition emits a notification, every attach/detach fires a
172
- * connection event), so nothing is polled. The failure mode is
173
- * conservative: a missed transition just leaves the daemon running.
135
+ * Graceful process exit for the `shutdown` RPC (`wave daemon stop`/restart).
136
+ * The bridge has already destroyed every session (each agent saved its
137
+ * transcript and drained auto-memory); here we drop all client sockets, close
138
+ * the listener, remove the socket file, then exit. The exit is guarded so a
139
+ * test process that mocks process.exit (throwing or no-op) still observes the
140
+ * teardown.
174
141
  */
175
- private evaluateIdle(): void {
176
- // A stopped/shutting-down daemon never (re)arms the idle timer — late
177
- // socket 'close' events (which fire after server.close resolves) must not
178
- // resurrect a timer after stop().
179
- if (this.shuttingDown || this.stopped) return;
180
- if (this.connections.size > 0 || !this.bridge.isIdle()) {
181
- this.clearIdleTimer();
182
- return;
183
- }
184
- if (this.idleTimer) return;
185
- this.idleTimer = setTimeout(() => {
186
- this.idleTimer = undefined;
187
- void this.shutdown();
188
- }, this.graceMs);
189
- }
190
-
191
- /**
192
- * Destroy the sessions (each agent saves its transcript and drains
193
- * auto-memory), close the listener, unlink the socket file, then exit.
194
- * `shuttingDown` guards against re-entry: new connections are refused and
195
- * further idle evaluations become no-ops.
196
- */
197
- private async shutdown(): Promise<void> {
198
- if (this.shuttingDown) return;
199
- this.shuttingDown = true;
200
- this.clearIdleTimer();
201
- // Destroy client sockets first so server.close() can complete (an open
202
- // socket keeps the close callback pending).
142
+ private shutdown(): void {
203
143
  for (const socket of this.sockets) socket.destroy();
204
144
  this.sockets.clear();
205
- await this.bridge.destroyAll();
206
- await this.stop();
145
+ this.server?.close();
146
+ this.server = undefined;
207
147
  try {
208
148
  fs.unlinkSync(this.socketPath);
209
149
  } catch {
210
- // Already gone — a stale file would be probed/unlinked on next start.
150
+ // Already gone — a stale file is probed/unlinked on next start.
151
+ }
152
+ try {
153
+ process.exit(0);
154
+ } catch {
155
+ // process.exit is mocked in tests — the teardown above is the effect.
211
156
  }
212
- process.exit(0);
213
157
  }
214
158
  }
@@ -66,12 +66,19 @@ export type RequestMethod =
66
66
  | "getFullMessageThread"
67
67
  | "setPermissionMode"
68
68
  | "getPermissionMode"
69
+ | "getPlanFile"
69
70
  | "getMcpServers"
70
71
  | "connectMcpServer"
71
72
  | "disconnectMcpServer"
73
+ | "removeMcpServer"
74
+ | "getMcpConfigPaths"
72
75
  | "getSlashCommands"
73
76
  | "getSubagentConfigurations"
74
77
  | "getSkillMetadata"
78
+ | "deleteSkill"
79
+ | "deleteSubagent"
80
+ | "getHooksByScope"
81
+ | "deleteHook"
75
82
  | "searchFiles"
76
83
  | "writeArtifactFile"
77
84
  | "getPromptHistory"
@@ -85,8 +92,12 @@ export type RequestMethod =
85
92
  | "listDaemonSessions"
86
93
  // Auth
87
94
  | "getAuthStatus"
95
+ | "getAccountInfo"
88
96
  | "login"
89
97
  | "logout"
98
+ // Memory files (user-level ~/.wave/AGENTS.md and project-level <workdir>/AGENTS.md)
99
+ | "getAgentsContent"
100
+ | "setAgentsContent"
90
101
  // Plugins
91
102
  | "listPlugins"
92
103
  | "installPlugin"
@@ -106,6 +117,7 @@ export type RequestMethod =
106
117
  // Git / worktree (global — no session required)
107
118
  | "listGitBranches"
108
119
  | "createWorktree"
120
+ | "getWorktreeChanges"
109
121
  | "removeWorktree";
110
122
 
111
123
  // ── Client → Server notification methods ────────────────────────
@@ -129,9 +141,6 @@ export type ServerNotificationMethod =
129
141
  | "permissionModeChange"
130
142
  | "mcpServersChange"
131
143
  | "workdirChange"
132
- | "bangMessageAdded"
133
- | "bangMessageUpdated"
134
- | "bangMessageCompleted"
135
144
  | "notificationMessageAdded"
136
145
  | "permissionRequest"
137
146
  | "authUrl"
@@ -139,7 +148,8 @@ export type ServerNotificationMethod =
139
148
  | "compactionStateChange"
140
149
  | "compactionContentUpdate"
141
150
  | "backgroundTasksChange"
142
- | "btwContent";
151
+ | "btwContent"
152
+ | "contextUsage";
143
153
 
144
154
  // ── Helper: is this a request (has id)? ─────────────────────────
145
155
 
package/src/stdio-cli.ts CHANGED
@@ -7,8 +7,62 @@
7
7
  */
8
8
 
9
9
  import { StdioServer } from "./stdio/stdioServer.js";
10
+ import { logger } from "./utils/logger.js";
11
+
12
+ /**
13
+ * Uncaught exceptions / unhandled rejections only print to stderr by
14
+ * default, which the host truncates to a small tail (and the process exits
15
+ * right after, so queued stderr can be lost entirely) — the real crash
16
+ * reason often never reaches the user. Log the full error to cli.log
17
+ * (synchronous file append, never truncated), best-effort it to stderr,
18
+ * then keep the exit(1) semantics.
19
+ */
20
+ function crashHandler(kind: string, error: unknown): void {
21
+ logger.error(`[stdio] ${kind}:`, error);
22
+ try {
23
+ process.stderr.write(
24
+ `[stdio] ${kind}: ${
25
+ error instanceof Error ? error.stack || error.message : String(error)
26
+ }\n`,
27
+ );
28
+ } catch {
29
+ // stderr failure must not mask the log-file entry above.
30
+ }
31
+ process.exit(1);
32
+ }
33
+
34
+ /**
35
+ * stdout carries the JSON-RPC channel in --stdio mode (one JSON object per
36
+ * line); stderr is reserved for logger output (see stdioServer.ts). A single
37
+ * stray `console.log` anywhere in the SDK/host dependency graph therefore
38
+ * corrupts that channel: the host skips the unparseable line, losing whatever
39
+ * JSON-RPC payload shared it (this is how `SessionService`'s
40
+ * `console.log("Restoring session: …")` showed up as
41
+ * `[wave-jsonrpc] Failed to parse: …` on every restore).
42
+ *
43
+ * Fixing individual call sites is a losing race against a graph this big, so
44
+ * stdio mode forces the contract instead: only `console.error`/`console.warn`
45
+ * (already stderr) may print, everything else is redirected to stderr.
46
+ */
47
+ export function guardStdoutForJsonRpc(): void {
48
+ for (const level of ["log", "info", "debug"] as const) {
49
+ console[level] = (...args: unknown[]) => {
50
+ console.error(...args);
51
+ };
52
+ }
53
+ }
10
54
 
11
55
  export async function startStdioCli(): Promise<void> {
56
+ guardStdoutForJsonRpc();
57
+ // Registered here (stdio mode only): the interactive CLI keeps Node's
58
+ // default behavior so the terminal shows the crash stack directly.
59
+ process.on("uncaughtException", (error) =>
60
+ crashHandler("uncaughtException", error),
61
+ );
62
+ process.on("unhandledRejection", (reason) =>
63
+ crashHandler("unhandledRejection", reason),
64
+ );
65
+
12
66
  const server = new StdioServer();
13
67
  server.start();
14
68
 
@@ -3,7 +3,8 @@ import type { Message } from "wave-agent-sdk";
3
3
  /**
4
4
  * 判断一条 user 消息能否作为 /rewind 检查点。
5
5
  * 后台任务通知(task_notification)、hook 注入的消息(source: "hook")
6
- * bang 命令消息都是系统生成、用户不可见的,不能作为回滚点。
6
+ * 都是系统生成、用户不可见的,不能作为回滚点。bash 模式命令消息
7
+ * (`!ls`)是用户真正输入,与 fork skill 命令消息一致,可作为回滚点。
7
8
  * CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
8
9
  */
9
10
  export function isUserCheckpointMessage(m: Message): boolean {
@@ -11,6 +12,5 @@ export function isUserCheckpointMessage(m: Message): boolean {
11
12
  if (m.blocks.some((b) => b.type === "task_notification")) return false;
12
13
  if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
13
14
  return false;
14
- if (m.blocks.some((b) => b.type === "bang")) return false;
15
15
  return true;
16
16
  }
@@ -323,6 +323,132 @@ function toExtendedLengthPath(worktreePath: string): string {
323
323
  return `\\\\?\\${absolute}`;
324
324
  }
325
325
 
326
+ /** First line of a captured stderr/message, trimmed and length-capped. */
327
+ function firstLine(text: string | undefined, max = 200): string {
328
+ if (!text) return "";
329
+ const line = text.trim().split("\n")[0].trim();
330
+ return line.length > max ? `${line.slice(0, max)}…` : line;
331
+ }
332
+
333
+ /**
334
+ * Summarise why `git worktree remove` failed. execFile rejections carry the
335
+ * exit code and the child's stderr; without them a removal failure is
336
+ * indistinguishable from a MAX_PATH refusal, a locking process, or a git error.
337
+ */
338
+ function describeGitFailure(error: unknown): string {
339
+ const e = error as {
340
+ code?: number | string;
341
+ signal?: string;
342
+ stderr?: string;
343
+ message?: string;
344
+ };
345
+ const parts: string[] = [];
346
+ if (e.code !== undefined) parts.push(`code=${e.code}`);
347
+ if (e.signal) parts.push(`signal=${e.signal}`);
348
+ const stderr = firstLine(e.stderr);
349
+ if (stderr) parts.push(`stderr=${stderr}`);
350
+ if (parts.length === 0) parts.push(`message=${firstLine(e.message)}`);
351
+ return parts.join(" ");
352
+ }
353
+
354
+ /** Summarise why the `fs.rmSync` fallback failed (code + offending path). */
355
+ function describeFsFailure(error: unknown): string {
356
+ const e = error as {
357
+ code?: string;
358
+ syscall?: string;
359
+ path?: string;
360
+ message?: string;
361
+ };
362
+ const parts: string[] = [];
363
+ if (e.code) parts.push(`code=${e.code}`);
364
+ if (e.syscall) parts.push(`syscall=${e.syscall}`);
365
+ if (e.path) parts.push(`at=${e.path}`);
366
+ if (parts.length === 0) parts.push(`message=${firstLine(e.message)}`);
367
+ return parts.join(" ");
368
+ }
369
+
370
+ /**
371
+ * Delete the worktree directory with `fs.rmSync`, returning the error when it
372
+ * fails (null on success). Node removes directory symlinks/junctions properly,
373
+ * which is exactly where git gives up on Windows.
374
+ */
375
+ function rmWorktreeDirWithFs(worktreePath: string): unknown | null {
376
+ try {
377
+ fs.rmSync(toExtendedLengthPath(worktreePath), {
378
+ recursive: true,
379
+ force: true,
380
+ });
381
+ return null;
382
+ } catch (error) {
383
+ return error;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Describe what is still on disk after a failed removal. Without this a
389
+ * failure log cannot distinguish "directory already gone" from "the whole
390
+ * checkout is still sitting there".
391
+ */
392
+ function probeResidue(worktreePath: string): string {
393
+ try {
394
+ if (!fs.existsSync(worktreePath)) return "none";
395
+ const entries = fs.readdirSync(worktreePath) as string[];
396
+ const head = entries.slice(0, 5).join(",");
397
+ return `${entries.length}[${head}${entries.length > 5 ? ",…" : ""}]`;
398
+ } catch (error) {
399
+ return `unknown(${(error as { code?: string }).code ?? "error"})`;
400
+ }
401
+ }
402
+
403
+ export interface WorktreeChanges {
404
+ /** Uncommitted (tracked-modified or untracked) files in the worktree. */
405
+ files: number;
406
+ /** Commits on the checked-out branch that the base branch does not have. */
407
+ commits: number;
408
+ }
409
+
410
+ /**
411
+ * Count what deleting a worktree would throw away, so the caller can warn
412
+ * before it happens. Both counts are best-effort: an unknown base branch (never
413
+ * fetched, branch rewritten) only zeroes the commit count, and a worktree that
414
+ * is gone or not a git repository reports `null` instead of a misleading 0.
415
+ *
416
+ * @param worktreePath Worktree directory to inspect
417
+ * @param baseBranch Branch the worktree was created from (defaults to the
418
+ * repository's default remote branch)
419
+ */
420
+ export async function getWorktreeChanges(
421
+ worktreePath: string,
422
+ baseBranch?: string,
423
+ ): Promise<WorktreeChanges | null> {
424
+ let files: number;
425
+ try {
426
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
427
+ cwd: worktreePath,
428
+ encoding: "utf8",
429
+ });
430
+ files = stdout.split("\n").filter((line) => line.trim().length > 0).length;
431
+ } catch {
432
+ return null;
433
+ }
434
+
435
+ let commits = 0;
436
+ try {
437
+ const base = baseBranch ?? getDefaultRemoteBranch(worktreePath);
438
+ const { stdout } = await execFileAsync(
439
+ "git",
440
+ ["rev-list", "--count", `${base}..HEAD`],
441
+ { cwd: worktreePath, encoding: "utf8" },
442
+ );
443
+ commits = Number.parseInt(stdout.trim(), 10) || 0;
444
+ } catch {
445
+ // Unknown base (e.g. the branch was never fetched): the commit count is
446
+ // unknowable, but the uncommitted files below are still real.
447
+ }
448
+
449
+ return { files, commits };
450
+ }
451
+
326
452
  /**
327
453
  * Remove a git worktree and its associated branch
328
454
  * @param session Worktree session details
@@ -332,7 +458,8 @@ function toExtendedLengthPath(worktreePath: string): string {
332
458
  * is MAX_PATH-limited — deep paths (e.g. node_modules) can fail with "Filename
333
459
  * too long", leaving an orphan directory. When git fails we fall back to
334
460
  * fs.rmSync with an extended-length path (bypasses MAX_PATH) and prune stale
335
- * metadata. Failures are logged but never block branch deletion.
461
+ * metadata. Failures are logged; when the directory survives, its branch is
462
+ * deliberately kept so the leftover checkout stays reachable through git.
336
463
  */
337
464
  export async function removeWorktree(session: WorktreeSession): Promise<void> {
338
465
  // Hook-based worktrees are removed by the WorktreeRemove hook; wave never
@@ -359,22 +486,6 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
359
486
 
360
487
  const repoRoot = session.repoRoot;
361
488
 
362
- // Get current branch in worktree before removing it
363
- let currentBranch: string | undefined;
364
- try {
365
- const { stdout } = await execFileAsync(
366
- "git",
367
- ["rev-parse", "--abbrev-ref", "HEAD"],
368
- {
369
- cwd: session.path,
370
- encoding: "utf8",
371
- },
372
- );
373
- currentBranch = stdout.trim();
374
- } catch {
375
- // Ignore errors getting current branch
376
- }
377
-
378
489
  // Remove worktree
379
490
  try {
380
491
  await execFileAsync(
@@ -384,18 +495,42 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
384
495
  cwd: repoRoot,
385
496
  },
386
497
  );
498
+ // git exits 0 even when it did not delete the directory: on Windows it
499
+ // cannot remove directory symlinks/junctions, which is exactly what pnpm's
500
+ // node_modules is made of — so it deletes the files, leaves the skeleton
501
+ // behind and still reports success. Trust the filesystem, not the exit code.
502
+ if (fs.existsSync(session.path)) {
503
+ logger.warn(
504
+ `git worktree remove reported success but the directory survived: ` +
505
+ `path=${session.path} residue=${probeResidue(session.path)} — ` +
506
+ `deleting with fs.rmSync instead`,
507
+ );
508
+ const rmError = rmWorktreeDirWithFs(session.path);
509
+ if (rmError) {
510
+ logger.error(
511
+ `Failed to remove worktree or branch: path=${session.path} ` +
512
+ `stage=fs(after git success) ${describeFsFailure(rmError)} ` +
513
+ `residue=${probeResidue(session.path)}`,
514
+ rmError,
515
+ );
516
+ }
517
+ }
387
518
  } catch (error: unknown) {
388
519
  logger.warn(
389
- "git worktree remove failed, falling back to fs.rmSync:",
520
+ `git worktree remove failed, falling back to fs.rmSync: ${describeGitFailure(error)}`,
390
521
  error,
391
522
  );
392
- try {
393
- fs.rmSync(toExtendedLengthPath(session.path), {
394
- recursive: true,
395
- force: true,
396
- });
397
- } catch (rmError: unknown) {
398
- logger.error("Failed to remove worktree or branch:", rmError);
523
+ const rmError = rmWorktreeDirWithFs(session.path);
524
+ if (rmError) {
525
+ // Removal failures are best-effort, so this line is often the only trace
526
+ // of an orphaned worktree directory: record which stage failed, why, and
527
+ // whether anything was left behind.
528
+ logger.error(
529
+ `Failed to remove worktree or branch: path=${session.path} stage=fs ` +
530
+ `${describeFsFailure(rmError)} residue=${probeResidue(session.path)} ` +
531
+ `git(${describeGitFailure(error)})`,
532
+ rmError,
533
+ );
399
534
  }
400
535
  // git removes worktree metadata before the working directory; prune any
401
536
  // leftovers in case git failed before deleting them.
@@ -408,7 +543,21 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
408
543
  }
409
544
  }
410
545
 
411
- // Delete original branch
546
+ // The directory, not any exit code, decides whether the removal happened. A
547
+ // surviving directory means its checkout (and any uncommitted work in it) is
548
+ // still on disk, and the branch is the only ref still leading back to it.
549
+ if (fs.existsSync(session.path)) {
550
+ logger.warn(
551
+ `Worktree directory survived removal — keeping ${session.branch} so the ` +
552
+ `leftover checkout stays reachable: path=${session.path} ` +
553
+ `residue=${probeResidue(session.path)}`,
554
+ );
555
+ return;
556
+ }
557
+
558
+ // Delete the worktree's own branch. Branches the user checked out inside the
559
+ // worktree are never touched — they may hold commits that are not reachable
560
+ // from anywhere else (aligned with Claude Code).
412
561
  try {
413
562
  await execFileAsync("git", ["branch", "-D", session.branch], {
414
563
  cwd: repoRoot,
@@ -416,28 +565,4 @@ export async function removeWorktree(session: WorktreeSession): Promise<void> {
416
565
  } catch {
417
566
  // Ignore errors deleting original branch
418
567
  }
419
-
420
- // Delete current branch if it's different and not a protected branch
421
- if (
422
- currentBranch &&
423
- currentBranch !== session.branch &&
424
- currentBranch !== "HEAD"
425
- ) {
426
- const defaultRemoteBranch = getDefaultRemoteBranch(repoRoot);
427
- const defaultBranchName = defaultRemoteBranch.split("/").pop();
428
-
429
- if (
430
- currentBranch !== defaultBranchName &&
431
- currentBranch !== "main" &&
432
- currentBranch !== "master"
433
- ) {
434
- try {
435
- await execFileAsync("git", ["branch", "-D", currentBranch], {
436
- cwd: repoRoot,
437
- });
438
- } catch {
439
- // Ignore errors deleting current branch
440
- }
441
- }
442
- }
443
568
  }
@@ -1,41 +0,0 @@
1
- import React from "react";
2
- import { Box, Text } from "ink";
3
- import type { BangBlock } from "wave-agent-sdk";
4
- import { getLastLines } from "wave-agent-sdk";
5
-
6
- interface BangDisplayProps {
7
- block: BangBlock;
8
- isExpanded?: boolean;
9
- }
10
-
11
- export const BangDisplay: React.FC<BangDisplayProps> = ({
12
- block,
13
- isExpanded = false,
14
- }) => {
15
- const { command, output, stage, exitCode } = block;
16
- const MAX_LINES = 3; // Set maximum display lines
17
-
18
- const getStatusColor = () => {
19
- if (stage === "running") return "yellow";
20
- if (exitCode === 0) return "green";
21
- if (exitCode !== null && exitCode !== 0) return "red";
22
- return "gray"; // Unknown state
23
- };
24
-
25
- return (
26
- <Box flexDirection="column">
27
- <Box>
28
- <Text color={getStatusColor()}>! </Text>
29
- <Text color="white">{command}</Text>
30
- </Box>
31
-
32
- {output && (
33
- <Box paddingLeft={2} overflow="hidden">
34
- <Text color="gray">
35
- {isExpanded ? output : getLastLines(output, MAX_LINES)}
36
- </Text>
37
- </Box>
38
- )}
39
- </Box>
40
- );
41
- };