ccmanager 4.1.11 → 4.1.12

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.
@@ -18,8 +18,6 @@ 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;
23
21
  private spawn;
24
22
  private resolvePreset;
25
23
  detectTerminalState(session: Session): SessionState;
@@ -41,10 +39,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
41
39
  private createTerminal;
42
40
  private shouldResetRestoreScrollback;
43
41
  private getRestoreSnapshot;
44
- private scheduleRestoreRefresh;
45
- private armRestoreRefreshTimer;
46
- private cancelRestoreRefresh;
47
- private fireRestoreRefresh;
48
42
  private createSessionInternal;
49
43
  /**
50
44
  * Create session with command preset using Effect-based error handling
@@ -21,15 +21,6 @@ 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;
33
24
  export class SessionManager extends EventEmitter {
34
25
  sessions;
35
26
  waitingWithBottomBorder = new Map();
@@ -37,8 +28,6 @@ export class SessionManager extends EventEmitter {
37
28
  autoApprovalDisabledWorktrees = new Set();
38
29
  restoringSessions = new Set();
39
30
  bufferedRestoreData = new Map();
40
- restoreRefreshTimers = new Map();
41
- restoreRefreshDeadlines = new Map();
42
31
  async spawn(command, args, worktreePath, options = {}) {
43
32
  const spawnOptions = {
44
33
  name: 'xterm-256color',
@@ -216,7 +205,7 @@ export class SessionManager extends EventEmitter {
216
205
  data.includes('\x1b[3J') ||
217
206
  data.includes('\x1bc'));
218
207
  }
219
- getRestoreSnapshot(session, options = {}) {
208
+ getRestoreSnapshot(session) {
220
209
  const activeBuffer = session.terminal.buffer.active;
221
210
  if (activeBuffer.type !== 'normal') {
222
211
  return session.serializer.serialize({
@@ -228,22 +217,6 @@ export class SessionManager extends EventEmitter {
228
217
  if (bufferLength === 0) {
229
218
  return '';
230
219
  }
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({
240
- scrollback: 0,
241
- excludeAltBuffer: true,
242
- });
243
- const cursorRow = normalBuffer.cursorY + 1;
244
- const cursorCol = normalBuffer.cursorX + 1;
245
- return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
246
- }
247
220
  const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
248
221
  const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
249
222
  const rangeEnd = bufferLength - 1;
@@ -258,56 +231,6 @@ export class SessionManager extends EventEmitter {
258
231
  const cursorCol = normalBuffer.cursorX + 1;
259
232
  return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
260
233
  }
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) {
268
- return;
269
- }
270
- const existing = this.restoreRefreshTimers.get(session.id);
271
- if (existing !== undefined) {
272
- clearTimeout(existing);
273
- this.restoreRefreshTimers.delete(session.id);
274
- }
275
- const remaining = deadline - Date.now();
276
- if (remaining <= 0) {
277
- this.fireRestoreRefresh(session);
278
- return;
279
- }
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);
286
- if (existing !== undefined) {
287
- clearTimeout(existing);
288
- this.restoreRefreshTimers.delete(session.id);
289
- }
290
- this.restoreRefreshDeadlines.delete(session.id);
291
- }
292
- fireRestoreRefresh(session) {
293
- this.restoreRefreshTimers.delete(session.id);
294
- this.restoreRefreshDeadlines.delete(session.id);
295
- if (!session.isActive) {
296
- return;
297
- }
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`);
309
- }
310
- }
311
234
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
312
235
  const existingSessions = this.getSessionsForWorktree(worktreePath);
313
236
  const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
@@ -411,12 +334,6 @@ export class SessionManager extends EventEmitter {
411
334
  session.terminal.buffer.normal.baseY;
412
335
  }
413
336
  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);
419
- }
420
337
  // Only emit data events when session is active
421
338
  if (session.isActive) {
422
339
  if (this.restoringSessions.has(session.id)) {
@@ -576,12 +493,10 @@ export class SessionManager extends EventEmitter {
576
493
  }
577
494
  }
578
495
  }
579
- this.scheduleRestoreRefresh(session);
580
496
  }
581
497
  else {
582
498
  this.restoringSessions.delete(session.id);
583
499
  this.bufferedRestoreData.delete(session.id);
584
- this.cancelRestoreRefresh(session);
585
500
  }
586
501
  }
587
502
  }
@@ -656,7 +571,6 @@ export class SessionManager extends EventEmitter {
656
571
  this.waitingWithBottomBorder.delete(sessionId);
657
572
  this.restoringSessions.delete(sessionId);
658
573
  this.bufferedRestoreData.delete(sessionId);
659
- this.cancelRestoreRefresh(session);
660
574
  this.emit('sessionDestroyed', session);
661
575
  }
662
576
  }
@@ -710,7 +624,6 @@ export class SessionManager extends EventEmitter {
710
624
  }
711
625
  this.sessions.delete(sessionId);
712
626
  this.waitingWithBottomBorder.delete(sessionId);
713
- this.cancelRestoreRefresh(session);
714
627
  this.emit('sessionDestroyed', session);
715
628
  },
716
629
  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,90 +872,6 @@ 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 () => {
903
- vi.useFakeTimers();
904
- try {
905
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
906
- id: '1',
907
- name: 'Main',
908
- command: 'claude',
909
- });
910
- vi.mocked(spawn).mockReturnValue(mockPty);
911
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
912
- session.terminal.buffer.normal.length = 1;
913
- const serializeMock = vi
914
- .spyOn(session.serializer, 'serialize')
915
- .mockReturnValue('snap');
916
- const restoreHandler = vi.fn();
917
- sessionManager.on('sessionRestore', restoreHandler);
918
- 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({
928
- scrollback: 0,
929
- excludeAltBuffer: true,
930
- });
931
- }
932
- finally {
933
- vi.useRealTimers();
934
- }
935
- });
936
- it('should force a refresh at the max-wait deadline even while data keeps streaming', async () => {
937
- vi.useFakeTimers();
938
- try {
939
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
940
- id: '1',
941
- name: 'Main',
942
- command: 'claude',
943
- });
944
- vi.mocked(spawn).mockReturnValue(mockPty);
945
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
946
- session.terminal.buffer.normal.length = 1;
947
- vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
948
- const restoreHandler = vi.fn();
949
- sessionManager.on('sessionRestore', restoreHandler);
950
- 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');
955
- }
956
- expect(restoreHandler).toHaveBeenCalledTimes(2);
957
- }
958
- finally {
959
- vi.useRealTimers();
960
- }
961
- });
962
- it('should cancel a scheduled refresh when the session is deactivated', async () => {
963
- vi.useFakeTimers();
964
- try {
965
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
966
- id: '1',
967
- name: 'Main',
968
- command: 'claude',
969
- });
970
- vi.mocked(spawn).mockReturnValue(mockPty);
971
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
972
- session.terminal.buffer.normal.length = 1;
973
- vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
974
- const restoreHandler = vi.fn();
975
- sessionManager.on('sessionRestore', restoreHandler);
976
- sessionManager.setSessionActive(session.id, true);
977
- expect(restoreHandler).toHaveBeenCalledTimes(1);
978
- sessionManager.setSessionActive(session.id, false);
979
- await vi.advanceTimersByTimeAsync(500);
980
- expect(restoreHandler).toHaveBeenCalledTimes(1);
981
- }
982
- finally {
983
- vi.useRealTimers();
984
- }
985
- });
986
875
  });
987
876
  describe('static methods', () => {
988
877
  describe('getSessionCounts', () => {
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.11",
3
+ "version": "4.1.12",
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.12",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.12",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.12",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.12",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.12"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",