wave-code 1.0.8 → 1.0.10

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/bin/wave-code.js CHANGED
@@ -1,5 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { readFileSync, writeSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // `wave -v` / `wave --version` must be fast: editors probe the installed CLI
8
+ // version on every launch (e.g. the desktop app's auto-update check). Loading
9
+ // the full app graph (wave-agent-sdk, ink, highlight.js, ...) just to print
10
+ // the version takes 2-3s+ on a warm machine and can exceed callers' probe
11
+ // timeouts on cold starts (AV scan of freshly installed files), which they
12
+ // misread as "CLI missing/corrupt" → spurious re-installs. Print the version
13
+ // straight from package.json and exit before touching the app graph.
14
+ const versionArgs = ["-v", "--version"];
15
+ if (process.argv.slice(2).some((a) => versionArgs.includes(a))) {
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const packageJson = JSON.parse(
18
+ readFileSync(path.resolve(__dirname, "../package.json"), "utf-8"),
19
+ );
20
+ writeSync(1, `${packageJson.version}\n`);
21
+ process.exit(0);
22
+ }
23
+
3
24
  // Import and start the CLI
4
25
  import("../dist/index.js")
5
26
  .then(async ({ main }) => {
@@ -14,6 +14,21 @@ export const useChat = () => {
14
14
  }
15
15
  return context;
16
16
  };
17
+ /**
18
+ * Snapshot a SDK message for consumer state. The SDK mutates its internal
19
+ * message blocks in-place BEFORE firing the delta callback (it writes the full
20
+ * accumulated value to the shared block, then computes the chunk delta by
21
+ * slicing the new value). A consumer that pushed the SDK message object by
22
+ * live reference would read the already-updated block and append the delta
23
+ * again — the first delta is double-counted ("LetLet me think..."), affecting
24
+ * reasoning and text content alike. See docs/specs/core/stream-content-updates.md.
25
+ * The clone must be at least one layer deep (message + blocks) so the in-place
26
+ * block mutation never leaks into consumer state.
27
+ */
28
+ const snapshotMessage = (message) => ({
29
+ ...message,
30
+ blocks: message.blocks.map((block) => ({ ...block })),
31
+ });
17
32
  /**
18
33
  * Window-concat throttle for pure-delta streaming updates: chunks arriving
19
34
  * within the cooldown window are merged so no delta is lost (a dropped delta
@@ -322,7 +337,7 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
322
337
  // the incremental callbacks in initializeAgent below.
323
338
  const refreshMessages = useCallback(() => {
324
339
  if (!isExpandedRef.current && agentRef.current) {
325
- const msgs = [...agentRef.current.messages];
340
+ const msgs = agentRef.current.messages.map(snapshotMessage);
326
341
  setMessages(msgs);
327
342
  setLatestTotalTokens(extractLatestTotalTokens(msgs));
328
343
  }
@@ -357,7 +372,9 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
357
372
  const last = msgs[msgs.length - 1];
358
373
  if (!last || last.role !== "user")
359
374
  return;
360
- setMessages((prev) => prev.some((m) => m.id === last.id) ? prev : [...prev, last]);
375
+ setMessages((prev) => prev.some((m) => m.id === last.id)
376
+ ? prev
377
+ : [...prev, snapshotMessage(last)]);
361
378
  },
362
379
  onAssistantMessageAdded: (messageId) => {
363
380
  if (isExpandedRef.current || !agentRef.current)
@@ -365,7 +382,9 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
365
382
  const msg = agentRef.current.messages.find((m) => m.id === messageId);
366
383
  if (!msg)
367
384
  return;
368
- setMessages((prev) => prev.some((m) => m.id === messageId) ? prev : [...prev, msg]);
385
+ setMessages((prev) => prev.some((m) => m.id === messageId)
386
+ ? prev
387
+ : [...prev, snapshotMessage(msg)]);
369
388
  },
370
389
  onAssistantContentUpdated: (params) => {
371
390
  if (isExpandedRef.current)
@@ -388,19 +407,20 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
388
407
  if (isExpandedRef.current)
389
408
  return;
390
409
  setMessages((prev) => {
391
- // Append to the last assistant message, or create one if none exists
392
- for (let i = prev.length - 1; i >= 0; i--) {
393
- if (prev[i].role === "assistant") {
394
- return prev.map((m, idx) => idx === i
395
- ? {
396
- ...m,
397
- blocks: [
398
- ...m.blocks,
399
- { type: "error", content: error },
400
- ],
401
- }
402
- : m);
403
- }
410
+ // Append to the LAST message only if it is an assistant message
411
+ // (the current turn's in-flight reply). If the last message is a
412
+ // user message, the error belongs BELOW it — create a new
413
+ // assistant message instead of polluting the stale assistant from
414
+ // an earlier turn (which surfaced the error above the latest
415
+ // user message and accumulated it there).
416
+ const last = prev[prev.length - 1];
417
+ if (last && last.role === "assistant") {
418
+ return prev.map((m, idx) => idx === prev.length - 1
419
+ ? {
420
+ ...m,
421
+ blocks: [...m.blocks, { type: "error", content: error }],
422
+ }
423
+ : m);
404
424
  }
405
425
  return [
406
426
  ...prev,
@@ -562,9 +582,10 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
562
582
  };
563
583
  agent.setWorktreeSession(session);
564
584
  }
565
- // Get initial state
585
+ // Get initial state — snapshot the SDK messages (never hold live
586
+ // references; see snapshotMessage)
566
587
  setSessionId(agent.sessionId);
567
- setMessages(agent.messages);
588
+ setMessages(agent.messages.map(snapshotMessage));
568
589
  setIsLoading(agent.isLoading);
569
590
  setLatestTotalTokens(extractLatestTotalTokens(agent.messages));
570
591
  setIsCommandRunning(agent.isCommandRunning);
@@ -9,13 +9,22 @@
9
9
  */
10
10
  import type { Readable, Writable } from "stream";
11
11
  import { AgentBridge } from "./agentBridge.js";
12
+ export interface JsonRpcConnectionOptions {
13
+ /** Called when the input stream reaches EOF (parent closed the pipe). Only
14
+ * stdio mode passes it — it must exit the process when its client dies;
15
+ * daemon mode never passes it (a detached client must not kill the daemon,
16
+ * see daemonServer.ts). */
17
+ onClose?: () => void;
18
+ }
12
19
  export declare class JsonRpcConnection {
13
20
  private input;
14
21
  private output;
15
22
  private bridge;
16
23
  private rl;
17
24
  private started;
18
- constructor(input: Readable, output: Writable, bridge: AgentBridge);
25
+ private stoppedByOwner;
26
+ private onClose?;
27
+ constructor(input: Readable, output: Writable, bridge: AgentBridge, options?: JsonRpcConnectionOptions);
19
28
  start(): void;
20
29
  stop(): void;
21
30
  handleLine(line: string): Promise<void>;
@@ -10,11 +10,13 @@
10
10
  import readline from "readline";
11
11
  import { PARSE_ERROR, INVALID_REQUEST, INTERNAL_ERROR, isRequest, isNotification, } from "./protocol.js";
12
12
  export class JsonRpcConnection {
13
- constructor(input, output, bridge) {
13
+ constructor(input, output, bridge, options = {}) {
14
14
  this.input = input;
15
15
  this.output = output;
16
16
  this.bridge = bridge;
17
17
  this.started = false;
18
+ this.stoppedByOwner = false;
19
+ this.onClose = options.onClose;
18
20
  }
19
21
  start() {
20
22
  if (this.started)
@@ -39,9 +41,15 @@ export class JsonRpcConnection {
39
41
  });
40
42
  this.rl.on("close", () => {
41
43
  this.started = false;
44
+ // stdin EOF from the owning client — not a deliberate stop(). stdio
45
+ // mode reacts by exiting the process (its parent is gone); daemon mode
46
+ // has no onClose so a detached client reset never kills the daemon.
47
+ if (!this.stoppedByOwner)
48
+ this.onClose?.();
42
49
  });
43
50
  }
44
51
  stop() {
52
+ this.stoppedByOwner = true;
45
53
  this.rl?.close();
46
54
  this.rl = undefined;
47
55
  this.started = false;
@@ -19,6 +19,14 @@ export declare class StdioServer {
19
19
  get agentBridge(): AgentBridge;
20
20
  start(): void;
21
21
  stop(): void;
22
+ /**
23
+ * The owning client closed stdin (EOF) — it's gone, so this process must
24
+ * follow it. destroyAll() saves each session's transcript and drains
25
+ * auto-memory first; a timeout bounds it so a stuck agent can't orphan the
26
+ * exit. This is the real-world "parent died" path: without it the process
27
+ * lingers forever whenever an agent keeps event-loop handles alive.
28
+ */
29
+ private onConnectionClosed;
22
30
  handleLine(line: string): Promise<void>;
23
31
  sendResponse(id: number | string | null, result?: unknown, error?: {
24
32
  code: number;
@@ -13,7 +13,7 @@ export class StdioServer {
13
13
  ...options.bridgeOptions,
14
14
  emit: (method, params, sessionId) => this.sendNotification(method, params, sessionId),
15
15
  });
16
- this.conn = new JsonRpcConnection(options.input ?? process.stdin, options.output ?? process.stdout, this.bridge);
16
+ this.conn = new JsonRpcConnection(options.input ?? process.stdin, options.output ?? process.stdout, this.bridge, { onClose: () => this.onConnectionClosed() });
17
17
  }
18
18
  get agentBridge() {
19
19
  return this.bridge;
@@ -24,6 +24,21 @@ export class StdioServer {
24
24
  stop() {
25
25
  this.conn.stop();
26
26
  }
27
+ /**
28
+ * The owning client closed stdin (EOF) — it's gone, so this process must
29
+ * follow it. destroyAll() saves each session's transcript and drains
30
+ * auto-memory first; a timeout bounds it so a stuck agent can't orphan the
31
+ * exit. This is the real-world "parent died" path: without it the process
32
+ * lingers forever whenever an agent keeps event-loop handles alive.
33
+ */
34
+ async onConnectionClosed() {
35
+ const SHUTDOWN_TIMEOUT_MS = 5000;
36
+ await Promise.race([
37
+ this.bridge.destroyAll(),
38
+ new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
39
+ ]);
40
+ process.exit(0);
41
+ }
27
42
  handleLine(line) {
28
43
  return this.conn.handleLine(line);
29
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,7 +43,7 @@
43
43
  "wrap-ansi": "^10.0.0",
44
44
  "yargs": "^17.7.2",
45
45
  "zod": "^3.23.8",
46
- "wave-agent-sdk": "1.0.8"
46
+ "wave-agent-sdk": "1.0.10"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/react": "^19.1.8",
@@ -168,6 +168,22 @@ interface StreamingUpdateParams {
168
168
  stage: "streaming" | "end";
169
169
  }
170
170
 
171
+ /**
172
+ * Snapshot a SDK message for consumer state. The SDK mutates its internal
173
+ * message blocks in-place BEFORE firing the delta callback (it writes the full
174
+ * accumulated value to the shared block, then computes the chunk delta by
175
+ * slicing the new value). A consumer that pushed the SDK message object by
176
+ * live reference would read the already-updated block and append the delta
177
+ * again — the first delta is double-counted ("LetLet me think..."), affecting
178
+ * reasoning and text content alike. See docs/specs/core/stream-content-updates.md.
179
+ * The clone must be at least one layer deep (message + blocks) so the in-place
180
+ * block mutation never leaks into consumer state.
181
+ */
182
+ const snapshotMessage = (message: Message): Message => ({
183
+ ...message,
184
+ blocks: message.blocks.map((block) => ({ ...block })),
185
+ });
186
+
171
187
  /**
172
188
  * Window-concat throttle for pure-delta streaming updates: chunks arriving
173
189
  * within the cooldown window are merged so no delta is lost (a dropped delta
@@ -605,7 +621,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
605
621
  // the incremental callbacks in initializeAgent below.
606
622
  const refreshMessages = useCallback(() => {
607
623
  if (!isExpandedRef.current && agentRef.current) {
608
- const msgs = [...agentRef.current.messages];
624
+ const msgs = agentRef.current.messages.map(snapshotMessage);
609
625
  setMessages(msgs);
610
626
  setLatestTotalTokens(extractLatestTotalTokens(msgs));
611
627
  }
@@ -656,7 +672,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
656
672
  const last = msgs[msgs.length - 1];
657
673
  if (!last || last.role !== "user") return;
658
674
  setMessages((prev) =>
659
- prev.some((m) => m.id === last.id) ? prev : [...prev, last],
675
+ prev.some((m) => m.id === last.id)
676
+ ? prev
677
+ : [...prev, snapshotMessage(last)],
660
678
  );
661
679
  },
662
680
  onAssistantMessageAdded: (messageId: string) => {
@@ -664,7 +682,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
664
682
  const msg = agentRef.current.messages.find((m) => m.id === messageId);
665
683
  if (!msg) return;
666
684
  setMessages((prev) =>
667
- prev.some((m) => m.id === messageId) ? prev : [...prev, msg],
685
+ prev.some((m) => m.id === messageId)
686
+ ? prev
687
+ : [...prev, snapshotMessage(msg)],
668
688
  );
669
689
  },
670
690
  onAssistantContentUpdated: (params) => {
@@ -683,21 +703,22 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
683
703
  onErrorBlockAdded: (error: string) => {
684
704
  if (isExpandedRef.current) return;
685
705
  setMessages((prev) => {
686
- // Append to the last assistant message, or create one if none exists
687
- for (let i = prev.length - 1; i >= 0; i--) {
688
- if (prev[i].role === "assistant") {
689
- return prev.map((m, idx) =>
690
- idx === i
691
- ? {
692
- ...m,
693
- blocks: [
694
- ...m.blocks,
695
- { type: "error", content: error },
696
- ],
697
- }
698
- : m,
699
- );
700
- }
706
+ // Append to the LAST message only if it is an assistant message
707
+ // (the current turn's in-flight reply). If the last message is a
708
+ // user message, the error belongs BELOW it — create a new
709
+ // assistant message instead of polluting the stale assistant from
710
+ // an earlier turn (which surfaced the error above the latest
711
+ // user message and accumulated it there).
712
+ const last = prev[prev.length - 1];
713
+ if (last && last.role === "assistant") {
714
+ return prev.map((m, idx) =>
715
+ idx === prev.length - 1
716
+ ? {
717
+ ...m,
718
+ blocks: [...m.blocks, { type: "error", content: error }],
719
+ }
720
+ : m,
721
+ );
701
722
  }
702
723
  return [
703
724
  ...prev,
@@ -889,9 +910,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
889
910
  agent.setWorktreeSession(session);
890
911
  }
891
912
 
892
- // Get initial state
913
+ // Get initial state — snapshot the SDK messages (never hold live
914
+ // references; see snapshotMessage)
893
915
  setSessionId(agent.sessionId);
894
- setMessages(agent.messages);
916
+ setMessages(agent.messages.map(snapshotMessage));
895
917
  setIsLoading(agent.isLoading);
896
918
  setLatestTotalTokens(extractLatestTotalTokens(agent.messages));
897
919
  setIsCommandRunning(agent.isCommandRunning);
@@ -22,15 +22,28 @@ import {
22
22
  isNotification,
23
23
  } from "./protocol.js";
24
24
 
25
+ export interface JsonRpcConnectionOptions {
26
+ /** Called when the input stream reaches EOF (parent closed the pipe). Only
27
+ * stdio mode passes it — it must exit the process when its client dies;
28
+ * daemon mode never passes it (a detached client must not kill the daemon,
29
+ * see daemonServer.ts). */
30
+ onClose?: () => void;
31
+ }
32
+
25
33
  export class JsonRpcConnection {
26
34
  private rl: readline.Interface | undefined;
27
35
  private started = false;
36
+ private stoppedByOwner = false;
37
+ private onClose?: () => void;
28
38
 
29
39
  constructor(
30
40
  private input: Readable,
31
41
  private output: Writable,
32
42
  private bridge: AgentBridge,
33
- ) {}
43
+ options: JsonRpcConnectionOptions = {},
44
+ ) {
45
+ this.onClose = options.onClose;
46
+ }
34
47
 
35
48
  start(): void {
36
49
  if (this.started) return;
@@ -58,10 +71,15 @@ export class JsonRpcConnection {
58
71
 
59
72
  this.rl.on("close", () => {
60
73
  this.started = false;
74
+ // stdin EOF from the owning client — not a deliberate stop(). stdio
75
+ // mode reacts by exiting the process (its parent is gone); daemon mode
76
+ // has no onClose so a detached client reset never kills the daemon.
77
+ if (!this.stoppedByOwner) this.onClose?.();
61
78
  });
62
79
  }
63
80
 
64
81
  stop(): void {
82
+ this.stoppedByOwner = true;
65
83
  this.rl?.close();
66
84
  this.rl = undefined;
67
85
  this.started = false;
@@ -30,6 +30,7 @@ export class StdioServer {
30
30
  options.input ?? process.stdin,
31
31
  options.output ?? process.stdout,
32
32
  this.bridge,
33
+ { onClose: () => this.onConnectionClosed() },
33
34
  );
34
35
  }
35
36
 
@@ -45,6 +46,22 @@ export class StdioServer {
45
46
  this.conn.stop();
46
47
  }
47
48
 
49
+ /**
50
+ * The owning client closed stdin (EOF) — it's gone, so this process must
51
+ * follow it. destroyAll() saves each session's transcript and drains
52
+ * auto-memory first; a timeout bounds it so a stuck agent can't orphan the
53
+ * exit. This is the real-world "parent died" path: without it the process
54
+ * lingers forever whenever an agent keeps event-loop handles alive.
55
+ */
56
+ private async onConnectionClosed(): Promise<void> {
57
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
58
+ await Promise.race([
59
+ this.bridge.destroyAll(),
60
+ new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
61
+ ]);
62
+ process.exit(0);
63
+ }
64
+
48
65
  handleLine(line: string): Promise<void> {
49
66
  return this.conn.handleLine(line);
50
67
  }