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
|
|
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.
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1.
|
|
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",
|