ccmanager 4.1.11 → 4.1.13

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.
@@ -64,19 +64,33 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
64
64
  // Clear screen when entering session
65
65
  stdout.write('\x1B[2J\x1B[H');
66
66
  // Restore the current terminal state from the headless xterm snapshot.
67
- // The xterm serialize addon relies on auto-wrap (DECAWM) being enabled to
68
- // render wrapped lines. It omits row separators for wrapped rows and expects
69
- // characters to naturally overflow to the next line, so auto-wrap must stay
70
- // enabled while writing the snapshot and only be disabled afterward.
67
+ // The xterm serialize addon relies on auto-wrap (DECAWM) being enabled
68
+ // to render wrapped lines. It omits row separators for wrapped rows
69
+ // and expects characters to naturally overflow to the next line, so
70
+ // re-enable DECAWM around the snapshot write and restore the live-TUI
71
+ // default afterward. This matters for both the synchronous initial
72
+ // restore and the deferred restore that may fire after Session.tsx
73
+ // has already disabled DECAWM for live TUI redraws.
71
74
  const handleSessionRestore = (restoredSession, restoreSnapshot) => {
72
75
  if (restoredSession.id === session.id) {
73
76
  if (restoreSnapshot.length > 0) {
74
- stdout.write(restoreSnapshot);
77
+ stdout.write(`\x1b[?7h${restoreSnapshot}\x1b[?7l`);
75
78
  }
76
79
  }
77
80
  };
78
81
  // Listen for restore event first
79
82
  sessionManager.on('sessionRestore', handleSessionRestore);
83
+ // Repaint the user's terminal viewport from the post-resize headless
84
+ // snapshot. Without this, Ink-based TUIs (e.g. Claude Code) re-emit
85
+ // their full static history on SIGWINCH, which the user's terminal
86
+ // appends below the (already-clipped) viewport, producing duplicated
87
+ // rows equal to the resize delta.
88
+ const handleSessionResize = (resizedSession, redrawPayload) => {
89
+ if (resizedSession.id === session.id && redrawPayload.length > 0) {
90
+ stdout.write(redrawPayload);
91
+ }
92
+ };
93
+ sessionManager.on('sessionResize', handleSessionResize);
80
94
  // Listen for session data events
81
95
  const handleSessionData = (activeSession, data) => {
82
96
  // Only handle data for our session
@@ -120,11 +134,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
120
134
  const handleResize = () => {
121
135
  const cols = process.stdout.columns || 80;
122
136
  const rows = process.stdout.rows || 24;
123
- session.process.resize(cols, rows);
124
- // Also resize the virtual terminal
125
- if (session.terminal) {
126
- session.terminal.resize(cols, rows);
127
- }
137
+ sessionManager.performResize(session.id, cols, rows);
128
138
  };
129
139
  stdout.on('resize', handleResize);
130
140
  return () => {
@@ -138,6 +148,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
138
148
  sessionManager.setSessionActive(session.id, false);
139
149
  // Remove event listeners
140
150
  sessionManager.off('sessionRestore', handleSessionRestore);
151
+ sessionManager.off('sessionResize', handleSessionResize);
141
152
  sessionManager.off('sessionData', handleSessionData);
142
153
  sessionManager.off('sessionExit', handleSessionExit);
143
154
  stdout.off('resize', handleResize);
@@ -96,7 +96,7 @@ describe('Session', () => {
96
96
  expect(terminalResize).toHaveBeenCalledWith(120, 40);
97
97
  expect(processResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
98
98
  expect(terminalResize.mock.invocationCallOrder[0] ?? 0).toBeLessThan(setSessionActive.mock.invocationCallOrder[0] ?? 0);
99
- expect(testState.stdout?.write).toHaveBeenNthCalledWith(2, '\nrestored');
99
+ expect(testState.stdout?.write).toHaveBeenNthCalledWith(2, '\x1b[?7h\nrestored\x1b[?7l');
100
100
  expect(testState.stdout?.write).toHaveBeenNthCalledWith(3, '\x1b[?7l');
101
101
  });
102
102
  });
@@ -18,8 +18,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
18
18
  private autoApprovalDisabledWorktrees;
19
19
  private restoringSessions;
20
20
  private bufferedRestoreData;
21
- private restoreRefreshTimers;
22
- private restoreRefreshDeadlines;
21
+ private resizingSessions;
22
+ private resizeSuppressTimers;
23
+ private restoreDeferTimers;
24
+ private restoreDeferDeadlines;
23
25
  private spawn;
24
26
  private resolvePreset;
25
27
  detectTerminalState(session: Session): SessionState;
@@ -41,10 +43,15 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
41
43
  private createTerminal;
42
44
  private shouldResetRestoreScrollback;
43
45
  private getRestoreSnapshot;
44
- private scheduleRestoreRefresh;
45
- private armRestoreRefreshTimer;
46
- private cancelRestoreRefresh;
47
- private fireRestoreRefresh;
46
+ private getViewportRedrawSnapshot;
47
+ /**
48
+ * Resize a session's PTY and headless terminal, then repaint the user's
49
+ * terminal viewport from the post-resize headless state. Live PTY → stdout
50
+ * forwarding is suppressed for a short window so the child's SIGWINCH
51
+ * redraw (which on Ink-based TUIs re-emits the full static history) cannot
52
+ * append duplicates below the repainted viewport.
53
+ */
54
+ performResize(sessionId: string, cols: number, rows: number): void;
48
55
  private createSessionInternal;
49
56
  /**
50
57
  * Create session with command preset using Effect-based error handling
@@ -78,6 +85,10 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
78
85
  getSessionById(id: string): Session | undefined;
79
86
  getSessionsForWorktree(worktreePath: string): Session[];
80
87
  setSessionActive(sessionId: string, active: boolean): void;
88
+ private emitRestoreSnapshot;
89
+ private armRestoreDeferTimer;
90
+ private fireRestoreDefer;
91
+ private cancelRestoreDefer;
81
92
  cancelAutoApproval(sessionId: string, reason?: string): void;
82
93
  toggleAutoApprovalForWorktree(worktreePath: string): boolean;
83
94
  isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
@@ -21,15 +21,18 @@ const { Terminal } = pkg;
21
21
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
22
  const TERMINAL_SCROLLBACK_LINES = 5000;
23
23
  const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
24
- // Claude Code's Ink-based renderer sometimes splits a single UI redraw across
25
- // multiple PTY writes with short time gaps. If we snapshot between chunks, the
26
- // resulting viewport can miss rows (e.g. empty middle area while the top/bottom
27
- // chrome already rendered). Re-emit the snapshot after the PTY output has been
28
- // quiet for this long so late chunks are accounted for.
29
- const RESTORE_REFRESH_QUIET_MS = 120;
30
- // Cap on how long we wait for quiet before forcing the refresh, so continuous
31
- // streaming output (e.g. a long busy turn) still produces an updated snapshot.
32
- const RESTORE_REFRESH_MAX_WAIT_MS = 400;
24
+ // How long to suppress PTY stdout forwarding after a viewport resize so the
25
+ // child process's SIGWINCH-triggered re-emission of static content does not
26
+ // duplicate already-displayed rows in the user's terminal.
27
+ const RESIZE_SUPPRESS_MS = 250;
28
+ // While a busy TUI is mid-frame across multiple PTY chunks, a synchronous
29
+ // viewport-only restore can capture an incomplete frame and paint a half-
30
+ // drawn screen. Defer the restore emit until PTY output has been quiet for
31
+ // this long (extended on each chunk) so the snapshot captures a coherent
32
+ // post-frame state. Capped by RESTORE_DEFER_MAX_MS so a continuously
33
+ // streaming session still restores within a small bounded delay.
34
+ const RESTORE_DEFER_QUIET_MS = 80;
35
+ const RESTORE_DEFER_MAX_MS = 250;
33
36
  export class SessionManager extends EventEmitter {
34
37
  sessions;
35
38
  waitingWithBottomBorder = new Map();
@@ -37,8 +40,10 @@ export class SessionManager extends EventEmitter {
37
40
  autoApprovalDisabledWorktrees = new Set();
38
41
  restoringSessions = new Set();
39
42
  bufferedRestoreData = new Map();
40
- restoreRefreshTimers = new Map();
41
- restoreRefreshDeadlines = new Map();
43
+ resizingSessions = new Set();
44
+ resizeSuppressTimers = new Map();
45
+ restoreDeferTimers = new Map();
46
+ restoreDeferDeadlines = new Map();
42
47
  async spawn(command, args, worktreePath, options = {}) {
43
48
  const spawnOptions = {
44
49
  name: 'xterm-256color',
@@ -194,6 +199,15 @@ export class SessionManager extends EventEmitter {
194
199
  ...additionalUpdates,
195
200
  }));
196
201
  if (oldState !== newState) {
202
+ // While busy, cursor-addressed footer redraws (spinner, token stats,
203
+ // "accept edits on …" line) can push ghost frames into scrollback as
204
+ // chat content scrolls. Once the busy turn ends, advance the restore
205
+ // baseline so the next session restore replays only post-busy
206
+ // scrollback and skips the ghost-bearing range.
207
+ if (oldState === 'busy' && newState !== 'busy') {
208
+ session.restoreScrollbackBaseLine =
209
+ session.terminal.buffer.normal.baseY;
210
+ }
197
211
  void Effect.runPromise(executeStatusHook(oldState, newState, session));
198
212
  this.emit('sessionStateChanged', session);
199
213
  }
@@ -216,7 +230,7 @@ export class SessionManager extends EventEmitter {
216
230
  data.includes('\x1b[3J') ||
217
231
  data.includes('\x1bc'));
218
232
  }
219
- getRestoreSnapshot(session, options = {}) {
233
+ getRestoreSnapshot(session) {
220
234
  const activeBuffer = session.terminal.buffer.active;
221
235
  if (activeBuffer.type !== 'normal') {
222
236
  return session.serializer.serialize({
@@ -228,21 +242,20 @@ export class SessionManager extends EventEmitter {
228
242
  if (bufferLength === 0) {
229
243
  return '';
230
244
  }
231
- // While the session is busy, cursor-addressed status-box redraws can push
232
- // stale frames into scrollback (e.g. Claude's spinner + token stats line).
233
- // Those ghost rows render as duplicated status bars when replayed, so
234
- // restore only the viewport during busy state. Refresh re-emits also
235
- // bypass scrollback to avoid duplicating history into real-terminal
236
- // scrollback on top of the initial emit.
237
- const isBusy = session.stateMutex.getSnapshot().state === 'busy';
238
- if (options.viewportOnly || isBusy) {
239
- const snapshot = session.serializer.serialize({
245
+ const cursorRow = normalBuffer.cursorY + 1;
246
+ const cursorCol = normalBuffer.cursorX + 1;
247
+ // When the live viewport shows a transient footer (spinner activity,
248
+ // token stats, persistent shift+tab footer, etc.), the renderer keeps
249
+ // redrawing it in place and earlier copies have likely been pushed into
250
+ // scrollback by chat output scrolling beneath it. Replaying that
251
+ // scrollback would paint duplicated footer rows, so emit only the
252
+ // viewport in this case.
253
+ if (session.stateDetector.hasTransientRenderFooter(session.terminal)) {
254
+ const viewportSnapshot = session.serializer.serialize({
240
255
  scrollback: 0,
241
256
  excludeAltBuffer: true,
242
257
  });
243
- const cursorRow = normalBuffer.cursorY + 1;
244
- const cursorCol = normalBuffer.cursorX + 1;
245
- return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
258
+ return `${viewportSnapshot}\x1b[${cursorRow};${cursorCol}H`;
246
259
  }
247
260
  const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
248
261
  const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
@@ -254,59 +267,75 @@ export class SessionManager extends EventEmitter {
254
267
  },
255
268
  excludeAltBuffer: true,
256
269
  });
270
+ return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
271
+ }
272
+ getViewportRedrawSnapshot(session) {
273
+ const snapshot = session.serializer.serialize({ scrollback: 0 });
274
+ if (snapshot.length === 0) {
275
+ return '';
276
+ }
277
+ const activeBuffer = session.terminal.buffer.active;
278
+ if (activeBuffer.type !== 'normal') {
279
+ // Serialized alternate-buffer output already carries its own cursor
280
+ // positioning, so do not append a cursor home sequence here.
281
+ return snapshot;
282
+ }
283
+ const normalBuffer = session.terminal.buffer.normal;
257
284
  const cursorRow = normalBuffer.cursorY + 1;
258
285
  const cursorCol = normalBuffer.cursorX + 1;
259
286
  return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
260
287
  }
261
- scheduleRestoreRefresh(session) {
262
- this.restoreRefreshDeadlines.set(session.id, Date.now() + RESTORE_REFRESH_MAX_WAIT_MS);
263
- this.armRestoreRefreshTimer(session);
264
- }
265
- armRestoreRefreshTimer(session) {
266
- const deadline = this.restoreRefreshDeadlines.get(session.id);
267
- if (deadline === undefined) {
288
+ /**
289
+ * Resize a session's PTY and headless terminal, then repaint the user's
290
+ * terminal viewport from the post-resize headless state. Live PTY → stdout
291
+ * forwarding is suppressed for a short window so the child's SIGWINCH
292
+ * redraw (which on Ink-based TUIs re-emits the full static history) cannot
293
+ * append duplicates below the repainted viewport.
294
+ */
295
+ performResize(sessionId, cols, rows) {
296
+ const session = this.sessions.get(sessionId);
297
+ if (!session) {
268
298
  return;
269
299
  }
270
- const existing = this.restoreRefreshTimers.get(session.id);
271
- if (existing !== undefined) {
272
- clearTimeout(existing);
273
- this.restoreRefreshTimers.delete(session.id);
300
+ try {
301
+ session.process.resize(cols, rows);
274
302
  }
275
- const remaining = deadline - Date.now();
276
- if (remaining <= 0) {
277
- this.fireRestoreRefresh(session);
278
- return;
303
+ catch {
304
+ /* empty */
279
305
  }
280
- const delay = Math.min(RESTORE_REFRESH_QUIET_MS, remaining);
281
- const timer = setTimeout(() => this.fireRestoreRefresh(session), delay);
282
- this.restoreRefreshTimers.set(session.id, timer);
283
- }
284
- cancelRestoreRefresh(session) {
285
- const existing = this.restoreRefreshTimers.get(session.id);
306
+ try {
307
+ session.terminal.resize(cols, rows);
308
+ }
309
+ catch {
310
+ /* empty */
311
+ }
312
+ this.resizingSessions.add(sessionId);
313
+ const existing = this.resizeSuppressTimers.get(sessionId);
286
314
  if (existing !== undefined) {
287
315
  clearTimeout(existing);
288
- this.restoreRefreshTimers.delete(session.id);
289
316
  }
290
- this.restoreRefreshDeadlines.delete(session.id);
291
- }
292
- fireRestoreRefresh(session) {
293
- this.restoreRefreshTimers.delete(session.id);
294
- this.restoreRefreshDeadlines.delete(session.id);
317
+ const timer = setTimeout(() => {
318
+ this.resizeSuppressTimers.delete(sessionId);
319
+ this.resizingSessions.delete(sessionId);
320
+ }, RESIZE_SUPPRESS_MS);
321
+ this.resizeSuppressTimers.set(sessionId, timer);
295
322
  if (!session.isActive) {
296
323
  return;
297
324
  }
298
- const snapshot = this.getRestoreSnapshot(session, { viewportOnly: true });
299
- if (snapshot.length > 0) {
300
- // \x1b[2J: clear the viewport before repainting — without this, a
301
- // refresh snapshot shorter than the already-displayed content leaves
302
- // a "ghost tail" of pre-refresh rows at the bottom.
303
- // \x1b[?7h / \x1b[?7l: Session.tsx disables auto-wrap (DECAWM) after
304
- // the initial restore, but SerializeAddon omits row separators for
305
- // wrapped lines and relies on DECAWM to advance to the next row
306
- // (see PR #276). Re-enable auto-wrap just for the snapshot write so
307
- // wrapped viewport rows don't overlap, then restore the TUI default.
308
- this.emit('sessionRestore', session, `\x1b[?7h\x1b[2J\x1b[H${snapshot}\x1b[?7l`);
325
+ const snapshot = this.getViewportRedrawSnapshot(session);
326
+ if (snapshot.length === 0) {
327
+ return;
309
328
  }
329
+ // \x1b[?7h ... \x1b[?7l: SerializeAddon's wrapped-row encoding relies
330
+ // on auto-wrap (DECAWM) being enabled to advance the cursor across
331
+ // row boundaries (see PR #276). Session.tsx keeps DECAWM disabled
332
+ // for live TUI redraws, so re-enable it just for the snapshot write
333
+ // and restore the live default afterward.
334
+ // \x1b[2J\x1b[H: clear the post-resize viewport so the snapshot paints
335
+ // onto a known canvas; rows that the child won't cover (e.g. trailing
336
+ // blanks after a height shrink) stay clean rather than retaining
337
+ // pre-resize content.
338
+ this.emit('sessionResize', session, `\x1b[?7h\x1b[2J\x1b[H${snapshot}\x1b[?7l`);
310
339
  }
311
340
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
312
341
  const existingSessions = this.getSessionsForWorktree(worktreePath);
@@ -411,11 +440,13 @@ export class SessionManager extends EventEmitter {
411
440
  session.terminal.buffer.normal.baseY;
412
441
  }
413
442
  session.lastActivity = new Date();
414
- // If a restore-refresh is pending, each incoming chunk resets the
415
- // quiet timer so the follow-up snapshot only fires after Claude's
416
- // multi-chunk redraw has settled.
417
- if (this.restoreRefreshDeadlines.has(session.id)) {
418
- this.armRestoreRefreshTimer(session);
443
+ // Deferred restore is waiting for the TUI to finish a frame. Reset
444
+ // the quiet timer with each chunk; the snapshot fired at the end of
445
+ // the quiet window will already include this data through the
446
+ // headless write above, so do not buffer or forward.
447
+ if (this.restoreDeferDeadlines.has(session.id)) {
448
+ this.armRestoreDeferTimer(session);
449
+ return;
419
450
  }
420
451
  // Only emit data events when session is active
421
452
  if (session.isActive) {
@@ -425,6 +456,15 @@ export class SessionManager extends EventEmitter {
425
456
  this.bufferedRestoreData.set(session.id, bufferedData);
426
457
  return;
427
458
  }
459
+ // During a viewport resize, the child process re-emits its full
460
+ // static content to adapt to new line-wrapping. The headless
461
+ // terminal already absorbed the data above, so the post-resize
462
+ // snapshot we wrote on resize is in sync. Drop the live forward
463
+ // here to keep the user's terminal from acquiring duplicated
464
+ // rows below the snapshot.
465
+ if (this.resizingSessions.has(session.id)) {
466
+ return;
467
+ }
428
468
  this.emit('sessionData', session, data);
429
469
  }
430
470
  });
@@ -560,31 +600,83 @@ export class SessionManager extends EventEmitter {
560
600
  if (active) {
561
601
  session.lastAccessedAt = Date.now();
562
602
  this.restoringSessions.add(session.id);
563
- try {
564
- const restoreSnapshot = this.getRestoreSnapshot(session);
565
- if (restoreSnapshot.length > 0) {
566
- this.emit('sessionRestore', session, restoreSnapshot);
567
- }
568
- }
569
- finally {
570
- this.restoringSessions.delete(session.id);
571
- const bufferedData = this.bufferedRestoreData.get(session.id);
572
- if (bufferedData && bufferedData.length > 0) {
573
- this.bufferedRestoreData.delete(session.id);
574
- for (const chunk of bufferedData) {
575
- this.emit('sessionData', session, chunk);
576
- }
577
- }
603
+ // While the TUI shows a busy footer it may be mid-frame across
604
+ // multiple PTY chunks; capturing the snapshot synchronously can
605
+ // surface a half-drawn viewport. Defer until PTY output is
606
+ // quiet so the snapshot reflects a coherent post-frame state.
607
+ if (session.stateDetector.hasTransientRenderFooter(session.terminal)) {
608
+ this.restoreDeferDeadlines.set(session.id, Date.now() + RESTORE_DEFER_MAX_MS);
609
+ this.armRestoreDeferTimer(session);
610
+ return;
578
611
  }
579
- this.scheduleRestoreRefresh(session);
612
+ this.emitRestoreSnapshot(session);
580
613
  }
581
614
  else {
582
615
  this.restoringSessions.delete(session.id);
583
616
  this.bufferedRestoreData.delete(session.id);
584
- this.cancelRestoreRefresh(session);
617
+ this.resizingSessions.delete(session.id);
618
+ const resizeTimer = this.resizeSuppressTimers.get(session.id);
619
+ if (resizeTimer !== undefined) {
620
+ clearTimeout(resizeTimer);
621
+ this.resizeSuppressTimers.delete(session.id);
622
+ }
623
+ this.cancelRestoreDefer(session.id);
624
+ }
625
+ }
626
+ }
627
+ emitRestoreSnapshot(session) {
628
+ try {
629
+ const restoreSnapshot = this.getRestoreSnapshot(session);
630
+ if (restoreSnapshot.length > 0) {
631
+ this.emit('sessionRestore', session, restoreSnapshot);
632
+ }
633
+ }
634
+ finally {
635
+ this.restoringSessions.delete(session.id);
636
+ const bufferedData = this.bufferedRestoreData.get(session.id);
637
+ if (bufferedData && bufferedData.length > 0) {
638
+ this.bufferedRestoreData.delete(session.id);
639
+ for (const chunk of bufferedData) {
640
+ this.emit('sessionData', session, chunk);
641
+ }
585
642
  }
586
643
  }
587
644
  }
645
+ armRestoreDeferTimer(session) {
646
+ const deadline = this.restoreDeferDeadlines.get(session.id);
647
+ if (deadline === undefined) {
648
+ return;
649
+ }
650
+ const existing = this.restoreDeferTimers.get(session.id);
651
+ if (existing !== undefined) {
652
+ clearTimeout(existing);
653
+ }
654
+ const remaining = deadline - Date.now();
655
+ if (remaining <= 0) {
656
+ this.fireRestoreDefer(session);
657
+ return;
658
+ }
659
+ const delay = Math.min(RESTORE_DEFER_QUIET_MS, remaining);
660
+ const timer = setTimeout(() => this.fireRestoreDefer(session), delay);
661
+ this.restoreDeferTimers.set(session.id, timer);
662
+ }
663
+ fireRestoreDefer(session) {
664
+ this.restoreDeferTimers.delete(session.id);
665
+ this.restoreDeferDeadlines.delete(session.id);
666
+ if (!session.isActive) {
667
+ this.restoringSessions.delete(session.id);
668
+ return;
669
+ }
670
+ this.emitRestoreSnapshot(session);
671
+ }
672
+ cancelRestoreDefer(sessionId) {
673
+ const timer = this.restoreDeferTimers.get(sessionId);
674
+ if (timer !== undefined) {
675
+ clearTimeout(timer);
676
+ this.restoreDeferTimers.delete(sessionId);
677
+ }
678
+ this.restoreDeferDeadlines.delete(sessionId);
679
+ }
588
680
  cancelAutoApproval(sessionId, reason = 'User input received') {
589
681
  const session = this.sessions.get(sessionId);
590
682
  if (!session) {
@@ -656,7 +748,13 @@ export class SessionManager extends EventEmitter {
656
748
  this.waitingWithBottomBorder.delete(sessionId);
657
749
  this.restoringSessions.delete(sessionId);
658
750
  this.bufferedRestoreData.delete(sessionId);
659
- this.cancelRestoreRefresh(session);
751
+ this.resizingSessions.delete(sessionId);
752
+ const resizeTimer = this.resizeSuppressTimers.get(sessionId);
753
+ if (resizeTimer !== undefined) {
754
+ clearTimeout(resizeTimer);
755
+ this.resizeSuppressTimers.delete(sessionId);
756
+ }
757
+ this.cancelRestoreDefer(sessionId);
660
758
  this.emit('sessionDestroyed', session);
661
759
  }
662
760
  }
@@ -710,7 +808,13 @@ export class SessionManager extends EventEmitter {
710
808
  }
711
809
  this.sessions.delete(sessionId);
712
810
  this.waitingWithBottomBorder.delete(sessionId);
713
- this.cancelRestoreRefresh(session);
811
+ this.resizingSessions.delete(sessionId);
812
+ const resizeTimer = this.resizeSuppressTimers.get(sessionId);
813
+ if (resizeTimer !== undefined) {
814
+ clearTimeout(resizeTimer);
815
+ this.resizeSuppressTimers.delete(sessionId);
816
+ }
817
+ this.cancelRestoreDefer(sessionId);
714
818
  this.emit('sessionDestroyed', session);
715
819
  },
716
820
  catch: (error) => {
@@ -782,7 +782,6 @@ describe('SessionManager', () => {
782
782
  });
783
783
  vi.mocked(spawn).mockReturnValue(mockPty);
784
784
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
785
- await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
786
785
  const normalBuffer = session.terminal.buffer.normal;
787
786
  normalBuffer.baseY = 260;
788
787
  normalBuffer.length = 300;
@@ -804,32 +803,6 @@ describe('SessionManager', () => {
804
803
  });
805
804
  expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
806
805
  });
807
- it('should emit a viewport-only restore snapshot while session is busy', async () => {
808
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
809
- id: '1',
810
- name: 'Main',
811
- command: 'claude',
812
- });
813
- vi.mocked(spawn).mockReturnValue(mockPty);
814
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
815
- const normalBuffer = session.terminal.buffer.normal;
816
- normalBuffer.baseY = 260;
817
- normalBuffer.length = 300;
818
- normalBuffer.cursorY = 7;
819
- normalBuffer.cursorX = 11;
820
- session.restoreScrollbackBaseLine = 120;
821
- const serializeMock = vi
822
- .spyOn(session.serializer, 'serialize')
823
- .mockReturnValue('\u001b[31mbusy-viewport\u001b[0m');
824
- const restoreHandler = vi.fn();
825
- sessionManager.on('sessionRestore', restoreHandler);
826
- sessionManager.setSessionActive(session.id, true);
827
- expect(serializeMock).toHaveBeenCalledWith({
828
- scrollback: 0,
829
- excludeAltBuffer: true,
830
- });
831
- expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mbusy-viewport\u001b[0m\u001b[8;12H');
832
- });
833
806
  it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
834
807
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
835
808
  id: '1',
@@ -899,7 +872,7 @@ describe('SessionManager', () => {
899
872
  sessionManager.setSessionActive(session.id, true);
900
873
  expect(eventOrder).toEqual(['restore', 'data']);
901
874
  });
902
- it('should re-emit a viewport-only snapshot after the PTY quiet period', async () => {
875
+ it('should defer the viewport-only restore until PTY output is quiet when a transient footer is visible', async () => {
903
876
  vi.useFakeTimers();
904
877
  try {
905
878
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -909,31 +882,34 @@ describe('SessionManager', () => {
909
882
  });
910
883
  vi.mocked(spawn).mockReturnValue(mockPty);
911
884
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
912
- session.terminal.buffer.normal.length = 1;
885
+ const normalBuffer = session.terminal.buffer.normal;
886
+ normalBuffer.baseY = 260;
887
+ normalBuffer.length = 300;
888
+ normalBuffer.cursorY = 7;
889
+ normalBuffer.cursorX = 11;
890
+ session.restoreScrollbackBaseLine = 120;
891
+ vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
913
892
  const serializeMock = vi
914
893
  .spyOn(session.serializer, 'serialize')
915
- .mockReturnValue('snap');
894
+ .mockReturnValue('viewport');
916
895
  const restoreHandler = vi.fn();
917
896
  sessionManager.on('sessionRestore', restoreHandler);
918
897
  sessionManager.setSessionActive(session.id, true);
919
- expect(restoreHandler).toHaveBeenCalledTimes(1);
920
- await vi.advanceTimersByTimeAsync(50);
921
- mockPty.emit('data', 'late-chunk');
922
- await vi.advanceTimersByTimeAsync(50);
923
- expect(restoreHandler).toHaveBeenCalledTimes(1);
924
- await vi.advanceTimersByTimeAsync(120);
925
- expect(restoreHandler).toHaveBeenCalledTimes(2);
926
- expect(restoreHandler.mock.calls[1]?.[1]).toMatch(/^\u001b\[\?7h\u001b\[2J\u001b\[Hsnap.*\u001b\[\?7l$/);
927
- expect(serializeMock).toHaveBeenLastCalledWith({
898
+ expect(restoreHandler).not.toHaveBeenCalled();
899
+ expect(serializeMock).not.toHaveBeenCalled();
900
+ vi.advanceTimersByTime(80);
901
+ expect(serializeMock).toHaveBeenCalledWith({
928
902
  scrollback: 0,
929
903
  excludeAltBuffer: true,
930
904
  });
905
+ expect(serializeMock).not.toHaveBeenCalledWith(expect.objectContaining({ range: expect.anything() }));
906
+ expect(restoreHandler).toHaveBeenCalledWith(session, 'viewport');
931
907
  }
932
908
  finally {
933
909
  vi.useRealTimers();
934
910
  }
935
911
  });
936
- it('should force a refresh at the max-wait deadline even while data keeps streaming', async () => {
912
+ it('should extend the restore quiet timer while PTY output keeps streaming, capped by the deadline', async () => {
937
913
  vi.useFakeTimers();
938
914
  try {
939
915
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -943,23 +919,32 @@ describe('SessionManager', () => {
943
919
  });
944
920
  vi.mocked(spawn).mockReturnValue(mockPty);
945
921
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
946
- session.terminal.buffer.normal.length = 1;
922
+ session.terminal.buffer.normal.length =
923
+ 1;
924
+ vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
947
925
  vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
948
926
  const restoreHandler = vi.fn();
949
927
  sessionManager.on('sessionRestore', restoreHandler);
950
928
  sessionManager.setSessionActive(session.id, true);
951
- expect(restoreHandler).toHaveBeenCalledTimes(1);
952
- for (let elapsed = 0; elapsed < 400; elapsed += 50) {
953
- await vi.advanceTimersByTimeAsync(50);
954
- mockPty.emit('data', 'chunk');
929
+ expect(restoreHandler).not.toHaveBeenCalled();
930
+ // Each chunk arrives within the quiet window and resets the timer
931
+ // without firing the snapshot. Three 50 ms ticks keep the cap
932
+ // (250 ms) safely ahead.
933
+ for (let i = 0; i < 3; i++) {
934
+ vi.advanceTimersByTime(50);
935
+ mockPty.emit('data', 'tick');
955
936
  }
956
- expect(restoreHandler).toHaveBeenCalledTimes(2);
937
+ expect(restoreHandler).not.toHaveBeenCalled();
938
+ // Advance past the cap so the snapshot fires even though chunks
939
+ // kept arriving inside the quiet window.
940
+ vi.advanceTimersByTime(150);
941
+ expect(restoreHandler).toHaveBeenCalledTimes(1);
957
942
  }
958
943
  finally {
959
944
  vi.useRealTimers();
960
945
  }
961
946
  });
962
- it('should cancel a scheduled refresh when the session is deactivated', async () => {
947
+ it('should cancel a pending deferred restore when the session is deactivated', async () => {
963
948
  vi.useFakeTimers();
964
949
  try {
965
950
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -969,20 +954,135 @@ describe('SessionManager', () => {
969
954
  });
970
955
  vi.mocked(spawn).mockReturnValue(mockPty);
971
956
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
972
- session.terminal.buffer.normal.length = 1;
957
+ vi.spyOn(session.stateDetector, 'hasTransientRenderFooter').mockReturnValue(true);
973
958
  vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
974
959
  const restoreHandler = vi.fn();
975
960
  sessionManager.on('sessionRestore', restoreHandler);
976
961
  sessionManager.setSessionActive(session.id, true);
977
- expect(restoreHandler).toHaveBeenCalledTimes(1);
978
962
  sessionManager.setSessionActive(session.id, false);
979
- await vi.advanceTimersByTimeAsync(500);
980
- expect(restoreHandler).toHaveBeenCalledTimes(1);
963
+ vi.advanceTimersByTime(500);
964
+ expect(restoreHandler).not.toHaveBeenCalled();
981
965
  }
982
966
  finally {
983
967
  vi.useRealTimers();
984
968
  }
985
969
  });
970
+ it('should advance the restore baseline when transitioning out of busy', async () => {
971
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
972
+ id: '1',
973
+ name: 'Main',
974
+ command: 'claude',
975
+ });
976
+ vi.mocked(spawn).mockReturnValue(mockPty);
977
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
978
+ session.terminal.buffer.normal.baseY = 42;
979
+ session.restoreScrollbackBaseLine = 5;
980
+ await session.stateMutex.update(data => ({ ...data, state: 'busy' }));
981
+ const updateState = sessionManager.updateSessionState.bind(sessionManager);
982
+ await updateState(session, 'idle');
983
+ expect(session.restoreScrollbackBaseLine).toBe(42);
984
+ });
985
+ it('should not advance the restore baseline on transitions that do not leave busy', async () => {
986
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
987
+ id: '1',
988
+ name: 'Main',
989
+ command: 'claude',
990
+ });
991
+ vi.mocked(spawn).mockReturnValue(mockPty);
992
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
993
+ session.terminal.buffer.normal.baseY = 42;
994
+ session.restoreScrollbackBaseLine = 5;
995
+ await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
996
+ const updateState = sessionManager.updateSessionState.bind(sessionManager);
997
+ await updateState(session, 'busy');
998
+ expect(session.restoreScrollbackBaseLine).toBe(5);
999
+ });
1000
+ });
1001
+ describe('performResize', () => {
1002
+ beforeEach(() => {
1003
+ vi.useFakeTimers();
1004
+ });
1005
+ afterEach(() => {
1006
+ vi.useRealTimers();
1007
+ });
1008
+ it('emits a wrapped viewport snapshot to repaint after resize', async () => {
1009
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
1010
+ id: '1',
1011
+ name: 'Main',
1012
+ command: 'claude',
1013
+ });
1014
+ vi.mocked(spawn).mockReturnValue(mockPty);
1015
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
1016
+ session.isActive = true;
1017
+ const normalBuffer = session.terminal.buffer.normal;
1018
+ normalBuffer.cursorY = 3;
1019
+ normalBuffer.cursorX = 4;
1020
+ const serializeMock = vi
1021
+ .spyOn(session.serializer, 'serialize')
1022
+ .mockReturnValue('VIEWPORT');
1023
+ const resizeHandler = vi.fn();
1024
+ sessionManager.on('sessionResize', resizeHandler);
1025
+ sessionManager.performResize(session.id, 100, 24);
1026
+ expect(session.process.resize).toHaveBeenCalledWith(100, 24);
1027
+ expect(session.terminal.resize).toHaveBeenCalledWith(100, 24);
1028
+ expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
1029
+ expect(resizeHandler).toHaveBeenCalledWith(session, '[?7hVIEWPORT[?7l');
1030
+ });
1031
+ it('suppresses live PTY → stdout forwarding for the post-resize quiet window', async () => {
1032
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
1033
+ id: '1',
1034
+ name: 'Main',
1035
+ command: 'claude',
1036
+ });
1037
+ vi.mocked(spawn).mockReturnValue(mockPty);
1038
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
1039
+ session.isActive = true;
1040
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('VIEWPORT');
1041
+ const dataHandler = vi.fn();
1042
+ sessionManager.on('sessionData', dataHandler);
1043
+ sessionManager.performResize(session.id, 100, 24);
1044
+ mockPty.emit('data', 'static-replay');
1045
+ expect(dataHandler).not.toHaveBeenCalled();
1046
+ vi.advanceTimersByTime(260);
1047
+ mockPty.emit('data', 'live-after-window');
1048
+ expect(dataHandler).toHaveBeenCalledTimes(1);
1049
+ expect(dataHandler).toHaveBeenCalledWith(session, 'live-after-window');
1050
+ });
1051
+ it('skips emitting a resize repaint for inactive sessions', async () => {
1052
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
1053
+ id: '1',
1054
+ name: 'Main',
1055
+ command: 'claude',
1056
+ });
1057
+ vi.mocked(spawn).mockReturnValue(mockPty);
1058
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
1059
+ session.isActive = false;
1060
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('VIEWPORT');
1061
+ const resizeHandler = vi.fn();
1062
+ sessionManager.on('sessionResize', resizeHandler);
1063
+ sessionManager.performResize(session.id, 100, 24);
1064
+ expect(resizeHandler).not.toHaveBeenCalled();
1065
+ expect(session.process.resize).toHaveBeenCalledWith(100, 24);
1066
+ });
1067
+ it('clears the resize suppression when the session is deactivated', async () => {
1068
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
1069
+ id: '1',
1070
+ name: 'Main',
1071
+ command: 'claude',
1072
+ });
1073
+ vi.mocked(spawn).mockReturnValue(mockPty);
1074
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
1075
+ session.isActive = true;
1076
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('VIEWPORT');
1077
+ const dataHandler = vi.fn();
1078
+ sessionManager.on('sessionData', dataHandler);
1079
+ sessionManager.performResize(session.id, 100, 24);
1080
+ sessionManager.setSessionActive(session.id, false);
1081
+ session.isActive = true;
1082
+ mockPty.emit('data', 'live-after-deactivate');
1083
+ expect(dataHandler).toHaveBeenCalledTimes(1);
1084
+ expect(dataHandler).toHaveBeenCalledWith(session, 'live-after-deactivate');
1085
+ });
986
1086
  });
987
1087
  describe('static methods', () => {
988
1088
  describe('getSessionCounts', () => {
@@ -6,4 +6,5 @@ export declare abstract class BaseStateDetector implements StateDetector {
6
6
  protected getTerminalContent(terminal: Terminal, maxLines: number): string;
7
7
  abstract detectBackgroundTask(terminal: Terminal): number;
8
8
  abstract detectTeamMembers(terminal: Terminal): number;
9
+ hasTransientRenderFooter(_terminal: Terminal): boolean;
9
10
  }
@@ -7,4 +7,7 @@ export class BaseStateDetector {
7
7
  getTerminalContent(terminal, maxLines) {
8
8
  return getTerminalScreenContent(terminal, maxLines);
9
9
  }
10
+ hasTransientRenderFooter(_terminal) {
11
+ return false;
12
+ }
10
13
  }
@@ -33,5 +33,6 @@ export declare class ClaudeStateDetector extends BaseStateDetector {
33
33
  private getRecentContentAbovePromptBox;
34
34
  detectState(terminal: Terminal, currentState: SessionState): SessionState;
35
35
  detectBackgroundTask(terminal: Terminal): number;
36
+ hasTransientRenderFooter(terminal: Terminal): boolean;
36
37
  detectTeamMembers(terminal: Terminal): number;
37
38
  }
@@ -145,6 +145,31 @@ export class ClaudeStateDetector extends BaseStateDetector {
145
145
  // No background task detected
146
146
  return 0;
147
147
  }
148
+ hasTransientRenderFooter(terminal) {
149
+ // Only flag busy-turn footer markers. The persistent "(shift+tab to
150
+ // cycle)" footer is always visible during an active Claude session
151
+ // even when idle, but it does not by itself indicate that recent
152
+ // scrolling pushed ghost frames into scrollback — only an in-flight
153
+ // busy turn does. busy → non-busy transitions advance the restore
154
+ // baseline separately so already-scrolled ghosts from a just-ended
155
+ // turn are not replayed either.
156
+ const viewport = this.getTerminalContent(terminal, terminal.rows);
157
+ if (viewport.length === 0) {
158
+ return false;
159
+ }
160
+ if (SPINNER_ACTIVITY_PATTERN.test(viewport)) {
161
+ return true;
162
+ }
163
+ if (TOKEN_STATS_LINE_PATTERN.test(viewport)) {
164
+ return true;
165
+ }
166
+ const lower = viewport.toLowerCase();
167
+ if (lower.includes('esc to interrupt') ||
168
+ lower.includes('ctrl+c to interrupt')) {
169
+ return true;
170
+ }
171
+ return false;
172
+ }
148
173
  detectTeamMembers(terminal) {
149
174
  const lines = this.getTerminalLines(terminal, 3);
150
175
  // Look for the team member line containing "shift+↑ to expand"
@@ -778,6 +778,62 @@ describe('ClaudeStateDetector', () => {
778
778
  expect(count).toBe(3);
779
779
  });
780
780
  });
781
+ describe('hasTransientRenderFooter', () => {
782
+ it('returns true when viewport contains spinner activity label', () => {
783
+ terminal = createMockTerminal([
784
+ '✶ Befuddling… (1m 1s · ↓ 283 tokens)',
785
+ '──────────────────────────────',
786
+ '❯',
787
+ '──────────────────────────────',
788
+ ]);
789
+ expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
790
+ });
791
+ it('returns true when viewport contains a token stats line', () => {
792
+ terminal = createMockTerminal([
793
+ '(9m 21s · ↓ 13.7k tokens)',
794
+ '──────────────────────────────',
795
+ '❯',
796
+ '──────────────────────────────',
797
+ ]);
798
+ expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
799
+ });
800
+ it('returns false for a steady idle viewport that only shows the persistent shift+tab footer', () => {
801
+ // The "(shift+tab to cycle)" line is rendered even when nothing
802
+ // is scrolling, so on its own it does not imply scrollback ghosts.
803
+ terminal = createMockTerminal([
804
+ 'Some idle conversation',
805
+ '──────────────────────────────',
806
+ '❯',
807
+ '──────────────────────────────',
808
+ '⏵⏵ accept edits on (shift+tab to cycle)',
809
+ ]);
810
+ expect(detector.hasTransientRenderFooter(terminal)).toBe(false);
811
+ });
812
+ it('returns true when viewport contains "esc to interrupt"', () => {
813
+ terminal = createMockTerminal([
814
+ 'Working...',
815
+ 'Press esc to interrupt',
816
+ '❯',
817
+ ]);
818
+ expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
819
+ });
820
+ it('returns true when viewport contains "ctrl+c to interrupt"', () => {
821
+ terminal = createMockTerminal(['Searching… (ctrl+c to interrupt)', '❯']);
822
+ expect(detector.hasTransientRenderFooter(terminal)).toBe(true);
823
+ });
824
+ it('returns false on a quiet idle viewport without footer markers', () => {
825
+ terminal = createMockTerminal([
826
+ 'Some output',
827
+ 'Command completed successfully',
828
+ '> ',
829
+ ]);
830
+ expect(detector.hasTransientRenderFooter(terminal)).toBe(false);
831
+ });
832
+ it('returns false for an empty terminal', () => {
833
+ terminal = createMockTerminal([]);
834
+ expect(detector.hasTransientRenderFooter(terminal)).toBe(false);
835
+ });
836
+ });
781
837
  describe('detectTeamMembers', () => {
782
838
  it('should return 2 when two @name members are present with shift+↑ to expand', () => {
783
839
  terminal = createMockTerminal([
@@ -15,7 +15,8 @@ export class CodexStateDetector extends BaseStateDetector {
15
15
  // Check for waiting prompts
16
16
  if (lowerContent.includes('allow command?') ||
17
17
  lowerContent.includes('[y/n]') ||
18
- lowerContent.includes('yes (y)')) {
18
+ lowerContent.includes('yes (y)') ||
19
+ lowerContent.includes('enter to submit')) {
19
20
  return 'waiting_input';
20
21
  }
21
22
  if (/(do you want|would you like)[\s\S]*?\n+[\s\S]*?\byes\b/.test(lowerContent)) {
@@ -171,4 +171,24 @@ describe('CodexStateDetector', () => {
171
171
  // Assert
172
172
  expect(state).toBe('waiting_input');
173
173
  });
174
+ it('should detect waiting_input for MCP tool permission prompt with "enter to submit"', () => {
175
+ // Arrange
176
+ terminal = createMockTerminal([
177
+ 'Field 1/1',
178
+ 'Allow the chrome-devtools MCP server to run tool "new_page"?',
179
+ '',
180
+ 'timeout: 10000',
181
+ 'url: http://localhost:4000/scenarios',
182
+ '',
183
+ '› 1. Allow Run the tool and continue.',
184
+ '2. Allow for this session Run the tool and remember this choice for this session.',
185
+ '3. Always allow Run the tool and remember this choice for future tool calls.',
186
+ '4. Cancel Cancel this tool call',
187
+ 'enter to submit | esc to cancel',
188
+ ]);
189
+ // Act
190
+ const state = detector.detectState(terminal, 'idle');
191
+ // Assert
192
+ expect(state).toBe('waiting_input');
193
+ });
174
194
  });
@@ -3,4 +3,5 @@ export interface StateDetector {
3
3
  detectState(terminal: Terminal, currentState: SessionState): SessionState;
4
4
  detectBackgroundTask(terminal: Terminal): number;
5
5
  detectTeamMembers(terminal: Terminal): number;
6
+ hasTransientRenderFooter(terminal: Terminal): boolean;
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.11",
3
+ "version": "4.1.13",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.11",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.11",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.11",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.11",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.11"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.13",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.13",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.13",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.13",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.13"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",