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.
@@ -1,5 +1,6 @@
1
1
  import { getAtSelectorPosition, getSlashSelectorPosition, getWordEnd, SELECTOR_TRIGGERS, getProjectedState, } from "../utils/inputUtils.js";
2
2
  import { AVAILABLE_COMMANDS } from "../constants/commands.js";
3
+ export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
3
4
  export const initialState = {
4
5
  inputText: "",
5
6
  cursorPosition: 0,
@@ -27,9 +28,6 @@ export const initialState = {
27
28
  showWorkflowManager: false,
28
29
  permissionMode: "default",
29
30
  selectorJustUsed: false,
30
- isPasting: false,
31
- pasteBuffer: "",
32
- initialPasteCursorPosition: 0,
33
31
  history: [],
34
32
  historyIndex: -1,
35
33
  originalInputText: "",
@@ -40,7 +38,146 @@ export const initialState = {
40
38
  isLoading: false,
41
39
  },
42
40
  pendingEffect: null,
41
+ escClearPending: false,
43
42
  };
43
+ /**
44
+ * Insert text at the cursor position, folding text longer than 200 chars
45
+ * into a [LongText#N] placeholder. Shared by the INSERT_TEXT_WITH_PLACEHOLDER
46
+ * action and multi-char chunk inserts (typed bursts, terminal paste, tmux
47
+ * send-keys).
48
+ */
49
+ function insertTextWithPlaceholder(textToInsert, state) {
50
+ let text = textToInsert;
51
+ let newLongTextCounter = state.longTextCounter;
52
+ const newLongTextMap = { ...state.longTextMap };
53
+ if (text.length > 200) {
54
+ newLongTextCounter += 1;
55
+ const placeholderLabel = `[LongText#${newLongTextCounter}]`;
56
+ newLongTextMap[placeholderLabel] = text;
57
+ text = placeholderLabel;
58
+ }
59
+ const beforeCursor = state.inputText.substring(0, state.cursorPosition);
60
+ const afterCursor = state.inputText.substring(state.cursorPosition);
61
+ const newText = beforeCursor + text + afterCursor;
62
+ const newCursorPosition = state.cursorPosition + text.length;
63
+ const newState = {
64
+ ...state,
65
+ inputText: newText,
66
+ cursorPosition: newCursorPosition,
67
+ longTextCounter: newLongTextCounter,
68
+ longTextMap: newLongTextMap,
69
+ historyIndex: -1,
70
+ };
71
+ // Sync selectors
72
+ const atPos = getAtSelectorPosition(newText, newCursorPosition);
73
+ if (atPos !== -1 && !newState.showFileSelector) {
74
+ newState.showFileSelector = true;
75
+ newState.atPosition = atPos;
76
+ newState.isFileSearching = true;
77
+ }
78
+ const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
79
+ if (slashPos !== -1 && !newState.showCommandSelector) {
80
+ newState.showCommandSelector = true;
81
+ newState.slashPosition = slashPos;
82
+ }
83
+ if (newState.showFileSelector && newState.atPosition >= 0) {
84
+ newState.fileSearchQuery = newText.substring(newState.atPosition + 1, newCursorPosition);
85
+ }
86
+ else if (newState.showCommandSelector && newState.slashPosition >= 0) {
87
+ newState.commandSearchQuery = newText.substring(newState.slashPosition + 1, newCursorPosition);
88
+ }
89
+ return newState;
90
+ }
91
+ /**
92
+ * Submit the current input text: extract [Image #N] references, route /btw
93
+ * and CLI-internal slash commands, otherwise send as a message. Returns null
94
+ * when there is nothing to submit (empty text, bare /btw).
95
+ */
96
+ function submitInput(state) {
97
+ if (!state.inputText.trim()) {
98
+ return null;
99
+ }
100
+ const imageRegex = /\[Image #(\d+)\]/g;
101
+ const matches = [...state.inputText.matchAll(imageRegex)];
102
+ const referencedImages = matches
103
+ .map((match) => {
104
+ const imageId = parseInt(match[1], 10);
105
+ return state.attachedImages.find((img) => img.id === imageId);
106
+ })
107
+ .filter((img) => img !== undefined)
108
+ .map((img) => ({ path: img.path, mimeType: img.mimeType }));
109
+ const contentWithPlaceholders = state.inputText
110
+ .replace(imageRegex, "")
111
+ .trim();
112
+ if (contentWithPlaceholders.startsWith("/btw ")) {
113
+ const question = contentWithPlaceholders.substring(5).trim();
114
+ if (!question) {
115
+ // Bare /btw with no question text — ignore
116
+ return null;
117
+ }
118
+ return {
119
+ ...state,
120
+ inputText: "",
121
+ cursorPosition: 0,
122
+ historyIndex: -1,
123
+ longTextMap: {},
124
+ attachedImages: [],
125
+ btwState: {
126
+ question,
127
+ isLoading: true,
128
+ answer: undefined,
129
+ },
130
+ pendingEffect: { type: "ASK_BTW", question },
131
+ };
132
+ }
133
+ if (contentWithPlaceholders === "/btw") {
134
+ // Bare /btw — ignore
135
+ return null;
136
+ }
137
+ // Check if the content is a CLI-internal slash command (help, tasks,
138
+ // etc.) that should be executed locally rather than sent as a message.
139
+ // Agent slash commands and unknown /commands always go to SEND_MESSAGE.
140
+ if (contentWithPlaceholders.startsWith("/")) {
141
+ const spaceIndex = contentWithPlaceholders.indexOf(" ");
142
+ const commandName = spaceIndex === -1
143
+ ? contentWithPlaceholders.substring(1)
144
+ : contentWithPlaceholders.substring(1, spaceIndex);
145
+ const isInternalCommand = AVAILABLE_COMMANDS.some((cmd) => cmd.id === commandName);
146
+ if (isInternalCommand) {
147
+ const argsText = spaceIndex === -1
148
+ ? undefined
149
+ : contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
150
+ undefined;
151
+ return {
152
+ ...state,
153
+ inputText: "",
154
+ cursorPosition: 0,
155
+ historyIndex: -1,
156
+ longTextMap: {},
157
+ attachedImages: [],
158
+ pendingEffect: {
159
+ type: "EXECUTE_COMMAND",
160
+ command: commandName,
161
+ args: argsText,
162
+ },
163
+ };
164
+ }
165
+ }
166
+ return {
167
+ ...state,
168
+ inputText: "",
169
+ cursorPosition: 0,
170
+ historyIndex: -1,
171
+ longTextMap: {},
172
+ attachedImages: [],
173
+ pendingEffect: {
174
+ type: "SEND_MESSAGE",
175
+ content: contentWithPlaceholders,
176
+ images: referencedImages.length > 0 ? referencedImages : undefined,
177
+ longTextMap: state.longTextMap,
178
+ },
179
+ };
180
+ }
44
181
  export function inputReducer(state, action) {
45
182
  switch (action.type) {
46
183
  case "SET_INPUT_TEXT":
@@ -225,48 +362,8 @@ export function inputReducer(state, action) {
225
362
  return { ...state, permissionMode: action.payload };
226
363
  case "SET_SELECTOR_JUST_USED":
227
364
  return { ...state, selectorJustUsed: action.payload };
228
- case "INSERT_TEXT_WITH_PLACEHOLDER": {
229
- let textToInsert = action.payload;
230
- let newLongTextCounter = state.longTextCounter;
231
- const newLongTextMap = { ...state.longTextMap };
232
- if (textToInsert.length > 200) {
233
- newLongTextCounter += 1;
234
- const placeholderLabel = `[LongText#${newLongTextCounter}]`;
235
- newLongTextMap[placeholderLabel] = textToInsert;
236
- textToInsert = placeholderLabel;
237
- }
238
- const beforeCursor = state.inputText.substring(0, state.cursorPosition);
239
- const afterCursor = state.inputText.substring(state.cursorPosition);
240
- const newText = beforeCursor + textToInsert + afterCursor;
241
- const newCursorPosition = state.cursorPosition + textToInsert.length;
242
- const newState = {
243
- ...state,
244
- inputText: newText,
245
- cursorPosition: newCursorPosition,
246
- longTextCounter: newLongTextCounter,
247
- longTextMap: newLongTextMap,
248
- historyIndex: -1,
249
- };
250
- // Sync selectors
251
- const atPos = getAtSelectorPosition(newText, newCursorPosition);
252
- if (atPos !== -1 && !newState.showFileSelector) {
253
- newState.showFileSelector = true;
254
- newState.atPosition = atPos;
255
- newState.isFileSearching = true;
256
- }
257
- const slashPos = getSlashSelectorPosition(newText, newCursorPosition);
258
- if (slashPos !== -1 && !newState.showCommandSelector) {
259
- newState.showCommandSelector = true;
260
- newState.slashPosition = slashPos;
261
- }
262
- if (newState.showFileSelector && newState.atPosition >= 0) {
263
- newState.fileSearchQuery = newText.substring(newState.atPosition + 1, newCursorPosition);
264
- }
265
- else if (newState.showCommandSelector && newState.slashPosition >= 0) {
266
- newState.commandSearchQuery = newText.substring(newState.slashPosition + 1, newCursorPosition);
267
- }
268
- return newState;
269
- }
365
+ case "INSERT_TEXT_WITH_PLACEHOLDER":
366
+ return insertTextWithPlaceholder(action.payload, state);
270
367
  case "CLEAR_LONG_TEXT_MAP":
271
368
  return { ...state, longTextMap: {} };
272
369
  case "CLEAR_INPUT":
@@ -276,39 +373,6 @@ export function inputReducer(state, action) {
276
373
  cursorPosition: 0,
277
374
  historyIndex: -1,
278
375
  };
279
- case "APPEND_PASTE_CHUNK": {
280
- // The reducer determines if this is a new paste or a continuation
281
- // by checking if pasteBuffer is already set. This avoids the
282
- // handler needing to track isPasting state, which can be stale
283
- // when multiple dispatches fire before React state updates.
284
- const isNewPaste = !state.pasteBuffer;
285
- return {
286
- ...state,
287
- isPasting: true,
288
- pasteBuffer: state.pasteBuffer + action.payload.chunk,
289
- initialPasteCursorPosition: isNewPaste
290
- ? action.payload.cursorPosition
291
- : state.initialPasteCursorPosition,
292
- };
293
- }
294
- case "START_PASTE":
295
- return {
296
- ...state,
297
- isPasting: true,
298
- pasteBuffer: action.payload.buffer,
299
- initialPasteCursorPosition: action.payload.cursorPosition,
300
- };
301
- case "APPEND_PASTE_BUFFER":
302
- return {
303
- ...state,
304
- pasteBuffer: state.pasteBuffer + action.payload,
305
- };
306
- case "END_PASTE":
307
- return {
308
- ...state,
309
- isPasting: false,
310
- pasteBuffer: "",
311
- };
312
376
  case "ADD_IMAGE_AND_INSERT_PLACEHOLDER": {
313
377
  const newImage = {
314
378
  id: state.imageIdCounter,
@@ -462,9 +526,73 @@ export function inputReducer(state, action) {
462
526
  };
463
527
  case "CLEAR_PENDING_EFFECT":
464
528
  return { ...state, pendingEffect: null };
529
+ case "RESET_ESC_CLEAR_PENDING":
530
+ return { ...state, escClearPending: false };
465
531
  case "HANDLE_KEY": {
466
532
  const { input, key } = action.payload;
467
533
  const hasQueuedMessages = action.payload.hasQueuedMessages ?? false;
534
+ const isIdle = action.payload.isIdle ?? false;
535
+ // 0. Raw DEL (\x7f) filtering.
536
+ // SSH/tmux auto-repeat backspace coalesces multiple DEL bytes into one
537
+ // chunk (e.g. "\x7f\x7f") that ink cannot parse into a key event, so it
538
+ // arrives as raw input and would otherwise be treated as a paste and
539
+ // inserted literally. Treat each DEL as a synchronous backspace instead
540
+ // (aligned with Claude Code Issue #1853).
541
+ if (!key.backspace && !key.delete && input.includes("\x7f")) {
542
+ const delCount = (input.match(/\x7f/g) || []).length;
543
+ if (state.showHistorySearch) {
544
+ return {
545
+ ...state,
546
+ historySearchQuery: state.historySearchQuery.slice(0, -delCount),
547
+ };
548
+ }
549
+ if (state.cursorPosition > 0) {
550
+ const newCursorPosition = Math.max(0, state.cursorPosition - delCount);
551
+ const newInputText = state.inputText.substring(0, newCursorPosition) +
552
+ state.inputText.substring(state.cursorPosition);
553
+ const newState = {
554
+ ...state,
555
+ inputText: newInputText,
556
+ cursorPosition: newCursorPosition,
557
+ historyIndex: -1,
558
+ };
559
+ // Deactivate selectors if their trigger character was deleted
560
+ if (newState.showFileSelector &&
561
+ newCursorPosition <= newState.atPosition) {
562
+ newState.showFileSelector = false;
563
+ newState.atPosition = -1;
564
+ newState.fileSearchQuery = "";
565
+ newState.isFileSearching = false;
566
+ }
567
+ if (newState.showCommandSelector &&
568
+ newCursorPosition <= newState.slashPosition) {
569
+ newState.showCommandSelector = false;
570
+ newState.slashPosition = -1;
571
+ newState.commandSearchQuery = "";
572
+ }
573
+ // Reactivate selectors if cursor is within a trigger word
574
+ const atPos = getAtSelectorPosition(newInputText, newCursorPosition);
575
+ if (atPos !== -1 && !state.showFileSelector) {
576
+ newState.showFileSelector = true;
577
+ newState.atPosition = atPos;
578
+ newState.isFileSearching = true;
579
+ }
580
+ const slashPos = getSlashSelectorPosition(newInputText, newCursorPosition);
581
+ if (slashPos !== -1 && !state.showCommandSelector) {
582
+ newState.showCommandSelector = true;
583
+ newState.slashPosition = slashPos;
584
+ }
585
+ // Update queries
586
+ if (newState.showFileSelector && newState.atPosition >= 0) {
587
+ newState.fileSearchQuery = newInputText.substring(newState.atPosition + 1, newCursorPosition);
588
+ }
589
+ if (newState.showCommandSelector && newState.slashPosition >= 0) {
590
+ newState.commandSearchQuery = newInputText.substring(newState.slashPosition + 1, newCursorPosition);
591
+ }
592
+ return newState;
593
+ }
594
+ return state;
595
+ }
468
596
  // 1. Escape Handling
469
597
  if (key.escape) {
470
598
  // Dismiss btw answer
@@ -526,7 +654,39 @@ export function inputReducer(state, action) {
526
654
  state.showPluginManager ||
527
655
  state.showModelSelector ||
528
656
  state.showWorkflowManager)) {
529
- return { ...state, pendingEffect: { type: "ABORT_MESSAGE" } };
657
+ // While AI is running (or any busy state) Esc keeps the abort
658
+ // semantics. Only when idle does Esc fall through to the text-level
659
+ // double-press clear (aligned with Claude Code's mutual-exclusion
660
+ // design: Esc aborts only when a task is running).
661
+ if (!isIdle) {
662
+ return {
663
+ ...state,
664
+ escClearPending: false,
665
+ pendingEffect: { type: "ABORT_MESSAGE" },
666
+ };
667
+ }
668
+ // Idle: double-press Esc clears the input and saves to history.
669
+ if (state.inputText) {
670
+ if (state.escClearPending) {
671
+ const originalText = state.inputText;
672
+ const originalLongTextMap = state.longTextMap;
673
+ return {
674
+ ...state,
675
+ inputText: "",
676
+ cursorPosition: 0,
677
+ historyIndex: -1,
678
+ longTextMap: {},
679
+ escClearPending: false,
680
+ pendingEffect: {
681
+ type: "SAVE_PROMPT_HISTORY",
682
+ content: originalText,
683
+ longTextMap: originalLongTextMap,
684
+ },
685
+ };
686
+ }
687
+ return { ...state, escClearPending: true };
688
+ }
689
+ return state;
530
690
  }
531
691
  return state;
532
692
  }
@@ -563,6 +723,55 @@ export function inputReducer(state, action) {
563
723
  pendingEffect: { type: "BACKGROUND_CURRENT_TASK" },
564
724
  };
565
725
  }
726
+ // Emacs-style line editing (aligned with Claude Code): Ctrl+A/E move the
727
+ // cursor to line start/end, Ctrl+U/K delete to line start/end, Ctrl+W
728
+ // deletes the word before the cursor. Skipped while a selector is open.
729
+ if (key.ctrl &&
730
+ input &&
731
+ !state.showFileSelector &&
732
+ !state.showCommandSelector &&
733
+ !state.showHistorySearch) {
734
+ const editKey = input.toLowerCase();
735
+ if (editKey === "a") {
736
+ return { ...state, cursorPosition: 0 };
737
+ }
738
+ if (editKey === "e") {
739
+ return { ...state, cursorPosition: state.inputText.length };
740
+ }
741
+ if (editKey === "u") {
742
+ return {
743
+ ...state,
744
+ inputText: state.inputText.substring(state.cursorPosition),
745
+ cursorPosition: 0,
746
+ historyIndex: -1,
747
+ };
748
+ }
749
+ if (editKey === "k") {
750
+ return {
751
+ ...state,
752
+ inputText: state.inputText.substring(0, state.cursorPosition),
753
+ historyIndex: -1,
754
+ };
755
+ }
756
+ if (editKey === "w") {
757
+ // Find the start of the word before the cursor (skip trailing
758
+ // whitespace, then the word itself).
759
+ let start = state.cursorPosition - 1;
760
+ while (start >= 0 && /\s/.test(state.inputText[start])) {
761
+ start--;
762
+ }
763
+ while (start >= 0 && !/\s/.test(state.inputText[start])) {
764
+ start--;
765
+ }
766
+ return {
767
+ ...state,
768
+ inputText: state.inputText.substring(0, start + 1) +
769
+ state.inputText.substring(state.cursorPosition),
770
+ cursorPosition: start + 1,
771
+ historyIndex: -1,
772
+ };
773
+ }
774
+ }
566
775
  // 4. History Navigation
567
776
  if (key.upArrow &&
568
777
  !state.showFileSelector &&
@@ -713,89 +922,7 @@ export function inputReducer(state, action) {
713
922
  }
714
923
  // 6. Return / Submit
715
924
  if (key.return) {
716
- if (state.inputText.trim()) {
717
- const imageRegex = /\[Image #(\d+)\]/g;
718
- const matches = [...state.inputText.matchAll(imageRegex)];
719
- const referencedImages = matches
720
- .map((match) => {
721
- const imageId = parseInt(match[1], 10);
722
- return state.attachedImages.find((img) => img.id === imageId);
723
- })
724
- .filter((img) => img !== undefined)
725
- .map((img) => ({ path: img.path, mimeType: img.mimeType }));
726
- const contentWithPlaceholders = state.inputText
727
- .replace(imageRegex, "")
728
- .trim();
729
- if (contentWithPlaceholders.startsWith("/btw ")) {
730
- const question = contentWithPlaceholders.substring(5).trim();
731
- if (!question) {
732
- // Bare /btw with no question text — ignore
733
- return state;
734
- }
735
- return {
736
- ...state,
737
- inputText: "",
738
- cursorPosition: 0,
739
- historyIndex: -1,
740
- longTextMap: {},
741
- attachedImages: [],
742
- btwState: {
743
- question,
744
- isLoading: true,
745
- answer: undefined,
746
- },
747
- pendingEffect: { type: "ASK_BTW", question },
748
- };
749
- }
750
- if (contentWithPlaceholders === "/btw") {
751
- // Bare /btw — ignore
752
- return state;
753
- }
754
- // Check if the content is a CLI-internal slash command (help, tasks,
755
- // etc.) that should be executed locally rather than sent as a message.
756
- // Agent slash commands and unknown /commands always go to SEND_MESSAGE.
757
- if (contentWithPlaceholders.startsWith("/")) {
758
- const spaceIndex = contentWithPlaceholders.indexOf(" ");
759
- const commandName = spaceIndex === -1
760
- ? contentWithPlaceholders.substring(1)
761
- : contentWithPlaceholders.substring(1, spaceIndex);
762
- const isInternalCommand = AVAILABLE_COMMANDS.some((cmd) => cmd.id === commandName);
763
- if (isInternalCommand) {
764
- const argsText = spaceIndex === -1
765
- ? undefined
766
- : contentWithPlaceholders.substring(spaceIndex + 1).trim() ||
767
- undefined;
768
- return {
769
- ...state,
770
- inputText: "",
771
- cursorPosition: 0,
772
- historyIndex: -1,
773
- longTextMap: {},
774
- attachedImages: [],
775
- pendingEffect: {
776
- type: "EXECUTE_COMMAND",
777
- command: commandName,
778
- args: argsText,
779
- },
780
- };
781
- }
782
- }
783
- return {
784
- ...state,
785
- inputText: "",
786
- cursorPosition: 0,
787
- historyIndex: -1,
788
- longTextMap: {},
789
- attachedImages: [],
790
- pendingEffect: {
791
- type: "SEND_MESSAGE",
792
- content: contentWithPlaceholders,
793
- images: referencedImages.length > 0 ? referencedImages : undefined,
794
- longTextMap: state.longTextMap,
795
- },
796
- };
797
- }
798
- return state;
925
+ return submitInput(state) ?? state;
799
926
  }
800
927
  // 7. Regular Input
801
928
  if (input &&
@@ -808,17 +935,26 @@ export function inputReducer(state, action) {
808
935
  !key.rightArrow &&
809
936
  !("home" in key && key.home) &&
810
937
  !("end" in key && key.end)) {
811
- const isPasteOperation = input.length > 1 || input.includes("\n") || input.includes("\r");
812
- if (isPasteOperation) {
813
- const isNewPaste = !state.pasteBuffer;
814
- return {
815
- ...state,
816
- isPasting: true,
817
- pasteBuffer: state.pasteBuffer + input,
818
- initialPasteCursorPosition: isNewPaste
819
- ? state.cursorPosition
820
- : state.initialPasteCursorPosition,
821
- };
938
+ // SSH-coalesced Enter: on slow links, "text" + Enter arrive as one
939
+ // chunk ("o\r"). ink's parseKeypress only matches a lone \r, so
940
+ // key.return is false here. Text with exactly one trailing \r is a
941
+ // coalesced Enter — strip the \r, insert, and submit immediately
942
+ // (aligned with Claude Code's useTextInput).
943
+ const isCoalescedEnter = input.length > 1 &&
944
+ input.endsWith("\r") &&
945
+ !input.slice(0, -1).includes("\r") &&
946
+ // Backslash+CR is a stale VS Code Shift+Enter binding, not a
947
+ // coalesced Enter — keep it as regular input.
948
+ input[input.length - 2] !== "\\";
949
+ if (isCoalescedEnter) {
950
+ const insertedState = insertTextWithPlaceholder(input.slice(0, -1), state);
951
+ return submitInput(insertedState) ?? insertedState;
952
+ }
953
+ if (input.length > 1) {
954
+ // Multi-char chunk (typed burst, terminal paste, tmux send-keys):
955
+ // insert immediately — no debounce or paste buffer. \r → \n
956
+ // normalizes carriage returns from CRLF terminals.
957
+ return insertTextWithPlaceholder(input.replace(/\r/g, "\n"), state);
822
958
  }
823
959
  else {
824
960
  let char = input;
package/dist/print-cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Agent, hasUncommittedChanges, hasNewCommits, getDefaultRemoteBranch, } from "wave-agent-sdk";
1
+ import { Agent, hasUncommittedChanges, hasNewCommits, getDefaultRemoteBranch, validateWorktreeRemovalPath, } from "wave-agent-sdk";
2
2
  import { displayUsageSummary } from "./utils/usageSummary.js";
3
3
  import { removeWorktree } from "./utils/worktree.js";
4
4
  function displayTimingInfo(startTime, showStats) {
@@ -129,21 +129,35 @@ export async function startPrintCli(options) {
129
129
  }
130
130
  // Display timing information
131
131
  displayTimingInfo(startTime, showStats);
132
- // Destroy agent and exit after sendMessage completes
133
- await agent.destroy();
134
- // Handle worktree cleanup for print mode
132
+ // Trigger WorktreeRemove hook (before destroy it needs a live agent) and
133
+ // decide whether the worktree is clean enough to remove
134
+ let cleanWorktree = false;
135
135
  if (worktreeSession) {
136
136
  const cwd = workdir || worktreeSession.path;
137
137
  const baseBranch = getDefaultRemoteBranch(cwd);
138
138
  const hasChanges = hasUncommittedChanges(cwd);
139
139
  const hasCommits = hasNewCommits(cwd, baseBranch);
140
- if (!hasChanges && !hasCommits) {
141
- await removeWorktree(worktreeSession);
140
+ cleanWorktree = !hasChanges && !hasCommits;
141
+ if (cleanWorktree) {
142
+ await agent.triggerWorktreeRemoveHook(worktreeSession.path);
142
143
  }
143
144
  else {
144
145
  process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
145
146
  }
146
147
  }
148
+ // Destroy agent and exit after sendMessage completes
149
+ await agent.destroy();
150
+ // Handle worktree cleanup for print mode (git removal stays after destroy)
151
+ if (worktreeSession && cleanWorktree) {
152
+ try {
153
+ validateWorktreeRemovalPath(worktreeSession.path, worktreeSession.repoRoot);
154
+ await removeWorktree(worktreeSession);
155
+ }
156
+ catch (error) {
157
+ // Never block print-mode exit on worktree cleanup failures
158
+ process.stdout.write(`\n⚠️ Skipping worktree removal: ${error.message}\n`);
159
+ }
160
+ }
147
161
  process.exit(0);
148
162
  }
149
163
  catch (error) {
@@ -162,20 +176,32 @@ export async function startPrintCli(options) {
162
176
  }
163
177
  // Display timing information even on error
164
178
  displayTimingInfo(startTime, showStats);
165
- await agent.destroy();
166
- // Handle worktree cleanup for print mode even on error
179
+ // Trigger WorktreeRemove hook (before destroy) when the worktree is clean
180
+ let cleanWorktree = false;
167
181
  if (worktreeSession) {
168
182
  const cwd = workdir || worktreeSession.path;
169
183
  const baseBranch = getDefaultRemoteBranch(cwd);
170
184
  const hasChanges = hasUncommittedChanges(cwd);
171
185
  const hasCommits = hasNewCommits(cwd, baseBranch);
172
- if (!hasChanges && !hasCommits) {
173
- await removeWorktree(worktreeSession);
186
+ cleanWorktree = !hasChanges && !hasCommits;
187
+ if (cleanWorktree) {
188
+ await agent.triggerWorktreeRemoveHook(worktreeSession.path);
174
189
  }
175
190
  else {
176
191
  process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
177
192
  }
178
193
  }
194
+ await agent.destroy();
195
+ // Handle worktree cleanup for print mode even on error
196
+ if (worktreeSession && cleanWorktree) {
197
+ try {
198
+ validateWorktreeRemovalPath(worktreeSession.path, worktreeSession.repoRoot);
199
+ await removeWorktree(worktreeSession);
200
+ }
201
+ catch (error) {
202
+ process.stdout.write(`\n⚠️ Skipping worktree removal: ${error.message}\n`);
203
+ }
204
+ }
179
205
  }
180
206
  process.exit(1);
181
207
  }