ccmanager 3.12.4 → 3.12.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ConfigureStatusHooks.js +1 -1
- package/dist/constants/statePersistence.d.ts +2 -2
- package/dist/constants/statePersistence.js +7 -4
- package/dist/services/sessionManager.js +1 -7
- package/dist/services/sessionManager.statePersistence.test.js +6 -7
- package/dist/services/stateDetector/claude.d.ts +7 -0
- package/dist/services/stateDetector/claude.js +33 -1
- package/dist/services/stateDetector/claude.test.js +31 -0
- package/dist/utils/hookExecutor.js +2 -0
- package/package.json +6 -6
|
@@ -105,7 +105,7 @@ const ConfigureStatusHooks = ({ onComplete, }) => {
|
|
|
105
105
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "green", children: "\u2713 Configuration saved successfully!" }) }));
|
|
106
106
|
}
|
|
107
107
|
if (view === 'edit') {
|
|
108
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
|
|
108
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_DIR, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
|
|
109
109
|
}
|
|
110
110
|
const scopeLabel = scope === 'project' ? 'Project' : 'Global';
|
|
111
111
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Status Hooks (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "\uD83D\uDCCB Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Set commands to run when session status changes:" }) }), _jsx(SelectInput, { items: getMenuItems(), onSelect: handleMenuSelect, isFocused: true, limit: 10 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to go back" }) })] }));
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const STATE_PERSISTENCE_DURATION_MS =
|
|
1
|
+
export declare const STATE_PERSISTENCE_DURATION_MS = 1000;
|
|
2
2
|
export declare const STATE_CHECK_INTERVAL_MS = 100;
|
|
3
|
-
export declare const STATE_MINIMUM_DURATION_MS =
|
|
3
|
+
export declare const STATE_MINIMUM_DURATION_MS = 1000;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// Duration in milliseconds that a detected state must persist before being confirmed
|
|
2
|
-
|
|
1
|
+
// Duration in milliseconds that a detected state must persist before being confirmed.
|
|
2
|
+
// A higher value prevents transient flicker (e.g., brief "idle" during terminal re-renders)
|
|
3
|
+
// at the cost of slightly slower state transitions.
|
|
4
|
+
export const STATE_PERSISTENCE_DURATION_MS = 1000;
|
|
3
5
|
// Check interval for state detection in milliseconds
|
|
4
6
|
export const STATE_CHECK_INTERVAL_MS = 100;
|
|
5
|
-
// Minimum duration in current state before allowing transition to a new state
|
|
6
|
-
|
|
7
|
+
// Minimum duration in current state before allowing transition to a new state.
|
|
8
|
+
// Prevents rapid back-and-forth flicker (e.g., busy → idle → busy).
|
|
9
|
+
export const STATE_MINIMUM_DURATION_MS = 1000;
|
|
@@ -194,18 +194,12 @@ export class SessionManager extends EventEmitter {
|
|
|
194
194
|
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
195
195
|
}
|
|
196
196
|
createTerminal() {
|
|
197
|
-
|
|
197
|
+
return new Terminal({
|
|
198
198
|
cols: process.stdout.columns || 80,
|
|
199
199
|
rows: process.stdout.rows || 24,
|
|
200
200
|
allowProposedApi: true,
|
|
201
201
|
logLevel: 'off',
|
|
202
202
|
});
|
|
203
|
-
// Disable auto-wrap to match the real terminal setting (Session.tsx sends
|
|
204
|
-
// \x1b[?7l to stdout). Without this, long lines wrap in xterm-headless but
|
|
205
|
-
// are clipped on the real terminal, causing Ink's cursor-up re-render to
|
|
206
|
-
// leave ghost content (old spinners, "esc to interrupt", etc.) in the buffer.
|
|
207
|
-
terminal.write('\x1b[?7l');
|
|
208
|
-
return terminal;
|
|
209
203
|
}
|
|
210
204
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
211
205
|
const id = this.createSessionId();
|
|
@@ -210,9 +210,8 @@ describe('SessionManager - State Persistence', () => {
|
|
|
210
210
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
211
211
|
// Simulate output that would trigger idle state
|
|
212
212
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
213
|
-
// Advance time
|
|
214
|
-
|
|
215
|
-
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS + STATE_CHECK_INTERVAL_MS * 2);
|
|
213
|
+
// Advance time less than persistence duration so transition is not yet confirmed
|
|
214
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS - STATE_CHECK_INTERVAL_MS);
|
|
216
215
|
// State should still be busy because minimum duration hasn't elapsed
|
|
217
216
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
218
217
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
@@ -251,10 +250,10 @@ describe('SessionManager - State Persistence', () => {
|
|
|
251
250
|
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
|
|
252
251
|
// Pending state should be set to idle
|
|
253
252
|
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
254
|
-
// Advance past persistence duration
|
|
255
|
-
// Since stateConfirmedAt was updated at ~2000ms,
|
|
256
|
-
//
|
|
257
|
-
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
|
|
253
|
+
// Advance past half the persistence duration but not fully
|
|
254
|
+
// Since stateConfirmedAt was updated at ~2000ms, this is not enough
|
|
255
|
+
// for the pending state to be confirmed
|
|
256
|
+
await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS / 2);
|
|
258
257
|
// State should still be busy because minimum duration since last busy detection hasn't elapsed
|
|
259
258
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
260
259
|
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
@@ -12,6 +12,13 @@ export declare class ClaudeStateDetector extends BaseStateDetector {
|
|
|
12
12
|
* If no prompt box is found, returns all content as fallback.
|
|
13
13
|
*/
|
|
14
14
|
private getContentAbovePromptBox;
|
|
15
|
+
/**
|
|
16
|
+
* Claude Code frequently redraws the lower pane using cursor-addressed updates.
|
|
17
|
+
* xterm's buffer can retain transient fragments from those redraws outside the
|
|
18
|
+
* latest visible content block, so busy detection should only inspect the most
|
|
19
|
+
* recent contiguous block directly above the prompt box.
|
|
20
|
+
*/
|
|
21
|
+
private getRecentContentAbovePromptBox;
|
|
15
22
|
detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
16
23
|
detectBackgroundTask(terminal: Terminal): number;
|
|
17
24
|
detectTeamMembers(terminal: Terminal): number;
|
|
@@ -3,6 +3,7 @@ import { BaseStateDetector } from './base.js';
|
|
|
3
3
|
const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆';
|
|
4
4
|
// Matches spinner activity labels like "✽ Tempering…" or "✳ Simplifying recompute_tangents…"
|
|
5
5
|
const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
|
|
6
|
+
const BUSY_LOOKBACK_LINES = 5;
|
|
6
7
|
export class ClaudeStateDetector extends BaseStateDetector {
|
|
7
8
|
/**
|
|
8
9
|
* Extract content above the prompt box.
|
|
@@ -29,6 +30,37 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
29
30
|
// No prompt box found, return all content
|
|
30
31
|
return lines.join('\n');
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Claude Code frequently redraws the lower pane using cursor-addressed updates.
|
|
35
|
+
* xterm's buffer can retain transient fragments from those redraws outside the
|
|
36
|
+
* latest visible content block, so busy detection should only inspect the most
|
|
37
|
+
* recent contiguous block directly above the prompt box.
|
|
38
|
+
*/
|
|
39
|
+
getRecentContentAbovePromptBox(terminal, maxLines) {
|
|
40
|
+
const lines = this.getContentAbovePromptBox(terminal, maxLines).split('\n');
|
|
41
|
+
while (lines.length > 0) {
|
|
42
|
+
const trimmed = lines[lines.length - 1].trim();
|
|
43
|
+
if (trimmed === '' || trimmed === '❯' || /^[-─\s]+$/.test(trimmed)) {
|
|
44
|
+
lines.pop();
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
if (lines.length === 0) {
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
let start = lines.length - 1;
|
|
53
|
+
while (start >= 0) {
|
|
54
|
+
const trimmed = lines[start].trim();
|
|
55
|
+
if (trimmed === '' || /^[-─\s]+$/.test(trimmed)) {
|
|
56
|
+
start++;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
start--;
|
|
60
|
+
}
|
|
61
|
+
const recentBlock = lines.slice(Math.max(start, 0));
|
|
62
|
+
return recentBlock.slice(-BUSY_LOOKBACK_LINES).join('\n');
|
|
63
|
+
}
|
|
32
64
|
detectState(terminal, currentState) {
|
|
33
65
|
// Check for search prompt (⌕ Search…) within 200 lines - always idle
|
|
34
66
|
const extendedContent = this.getTerminalContent(terminal, 200);
|
|
@@ -56,7 +88,7 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
56
88
|
return 'waiting_input';
|
|
57
89
|
}
|
|
58
90
|
// Content above the prompt box only for busy detection
|
|
59
|
-
const abovePromptBox = this.
|
|
91
|
+
const abovePromptBox = this.getRecentContentAbovePromptBox(terminal, 30);
|
|
60
92
|
const aboveLowerContent = abovePromptBox.toLowerCase();
|
|
61
93
|
// Check for busy state
|
|
62
94
|
if (aboveLowerContent.includes('esc to interrupt') ||
|
|
@@ -423,6 +423,37 @@ describe('ClaudeStateDetector', () => {
|
|
|
423
423
|
// Assert - Should be idle because search prompt takes precedence
|
|
424
424
|
expect(state).toBe('idle');
|
|
425
425
|
});
|
|
426
|
+
it('should ignore stale spinner output outside the latest block above the prompt box', () => {
|
|
427
|
+
terminal = createMockTerminal([
|
|
428
|
+
'✻ Seasoning… (44s · ↓ 247 tokens)',
|
|
429
|
+
' ⎿ Tip: Use /btw to ask a quick side question',
|
|
430
|
+
'',
|
|
431
|
+
'⏺ 全て通過。',
|
|
432
|
+
'',
|
|
433
|
+
' - lint: pass (0 errors)',
|
|
434
|
+
' - typecheck: pass',
|
|
435
|
+
' - tests: 56 files, 775 passed, 5 skipped',
|
|
436
|
+
'──────────────────────────────',
|
|
437
|
+
'❯',
|
|
438
|
+
'──────────────────────────────',
|
|
439
|
+
]);
|
|
440
|
+
const state = detector.detectState(terminal, 'busy');
|
|
441
|
+
expect(state).toBe('idle');
|
|
442
|
+
});
|
|
443
|
+
it('should ignore stale interrupt text outside the latest block above the prompt box', () => {
|
|
444
|
+
terminal = createMockTerminal([
|
|
445
|
+
'Press esc to interrupt',
|
|
446
|
+
'Working...',
|
|
447
|
+
'',
|
|
448
|
+
'Command completed successfully',
|
|
449
|
+
'Ready for next command',
|
|
450
|
+
'──────────────────────────────',
|
|
451
|
+
'❯',
|
|
452
|
+
'──────────────────────────────',
|
|
453
|
+
]);
|
|
454
|
+
const state = detector.detectState(terminal, 'busy');
|
|
455
|
+
expect(state).toBe('idle');
|
|
456
|
+
});
|
|
426
457
|
it('should ignore "esc to interrupt" inside prompt box', () => {
|
|
427
458
|
// Arrange - "esc to interrupt" is inside the prompt box, not above it
|
|
428
459
|
terminal = createMockTerminal([
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
+
import { dirname } from 'path';
|
|
2
3
|
import { Effect } from 'effect';
|
|
3
4
|
import { ProcessError } from '../types/errors.js';
|
|
4
5
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
@@ -148,6 +149,7 @@ export function executeStatusHook(oldState, newState, session) {
|
|
|
148
149
|
// Build environment for status hook
|
|
149
150
|
const environment = {
|
|
150
151
|
CCMANAGER_WORKTREE_PATH: session.worktreePath,
|
|
152
|
+
CCMANAGER_WORKTREE_DIR: dirname(session.worktreePath),
|
|
151
153
|
CCMANAGER_WORKTREE_BRANCH: branch,
|
|
152
154
|
CCMANAGER_GIT_ROOT: session.worktreePath, // For status hooks, we use worktree path as cwd
|
|
153
155
|
CCMANAGER_OLD_STATE: oldState,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.12.
|
|
3
|
+
"version": "3.12.6",
|
|
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": "3.12.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.12.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.12.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.12.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.12.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.12.6",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.12.6",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.12.6",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.12.6",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.12.6"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|