ccmanager 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +54 -0
  2. package/dist/components/App.js +92 -30
  3. package/dist/components/App.test.js +86 -0
  4. package/dist/components/Dashboard.js +10 -3
  5. package/dist/components/DeleteWorktree.js +2 -13
  6. package/dist/components/Menu.js +28 -17
  7. package/dist/components/Menu.test.js +37 -1
  8. package/dist/components/RestoreSessions.d.ts +19 -0
  9. package/dist/components/RestoreSessions.js +28 -0
  10. package/dist/components/SessionActions.d.ts +17 -2
  11. package/dist/components/SessionActions.js +19 -17
  12. package/dist/components/SessionActions.test.d.ts +1 -0
  13. package/dist/components/SessionActions.test.js +94 -0
  14. package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
  15. package/dist/hooks/useAvailableLabelWidth.js +15 -0
  16. package/dist/services/config/globalConfigManager.js +3 -12
  17. package/dist/services/globalSessionOrchestrator.d.ts +7 -0
  18. package/dist/services/globalSessionOrchestrator.js +40 -0
  19. package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
  20. package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
  21. package/dist/services/projectManager.js +3 -11
  22. package/dist/services/sessionManager.d.ts +6 -0
  23. package/dist/services/sessionManager.js +16 -0
  24. package/dist/services/sessionRestoreStore.d.ts +75 -0
  25. package/dist/services/sessionRestoreStore.js +138 -0
  26. package/dist/services/sessionRestoreStore.test.d.ts +1 -0
  27. package/dist/services/sessionRestoreStore.test.js +92 -0
  28. package/dist/services/sessionRestorer.d.ts +46 -0
  29. package/dist/services/sessionRestorer.js +123 -0
  30. package/dist/services/sessionRestorer.test.d.ts +1 -0
  31. package/dist/services/sessionRestorer.test.js +163 -0
  32. package/dist/services/worktreeService.d.ts +12 -0
  33. package/dist/services/worktreeService.js +30 -0
  34. package/dist/services/worktreeService.test.js +55 -1
  35. package/dist/types/index.d.ts +3 -2
  36. package/dist/utils/configDir.d.ts +10 -0
  37. package/dist/utils/configDir.js +31 -0
  38. package/dist/utils/errorMessage.d.ts +7 -0
  39. package/dist/utils/errorMessage.js +19 -0
  40. package/dist/utils/filterByQuery.test.js +2 -0
  41. package/dist/utils/gitUtils.d.ts +1 -0
  42. package/dist/utils/gitUtils.js +15 -0
  43. package/dist/utils/hookExecutor.test.js +4 -0
  44. package/dist/utils/worktreeInclude.d.ts +33 -0
  45. package/dist/utils/worktreeInclude.js +100 -0
  46. package/dist/utils/worktreeInclude.test.d.ts +1 -0
  47. package/dist/utils/worktreeInclude.test.js +91 -0
  48. package/dist/utils/worktreeUtils.d.ts +38 -4
  49. package/dist/utils/worktreeUtils.js +76 -17
  50. package/dist/utils/worktreeUtils.test.js +82 -5
  51. package/package.json +6 -6
@@ -319,8 +319,44 @@ describe('Menu component Effect-based error handling', () => {
319
319
  }
320
320
  expect(onMenuAction).toHaveBeenCalledWith({
321
321
  type: 'sessionActions',
322
+ worktree: cachedWorktree,
322
323
  session: cachedSession,
323
- worktreePath: '/test/cached',
324
+ });
325
+ });
326
+ it('should open the actions menu with Space on a worktree row that has no session', async () => {
327
+ const { Effect } = await import('effect');
328
+ const sessionlessWorktree = {
329
+ path: '/test/no-session',
330
+ branch: 'feature/no-session',
331
+ isMainWorktree: false,
332
+ hasSession: false,
333
+ };
334
+ vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
335
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed([sessionlessWorktree]));
336
+ vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
337
+ const onMenuAction = vi.fn();
338
+ vi.mocked(useInput).mockClear();
339
+ render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
340
+ worktrees: [sessionlessWorktree],
341
+ defaultBranch: 'main',
342
+ }, onMenuAction: onMenuAction, version: "test" }));
343
+ await new Promise(resolve => setTimeout(resolve, 0));
344
+ // Menu's hotkey handler bails out when raw mode is unavailable.
345
+ const origSetRawMode = process.stdin.setRawMode;
346
+ process.stdin.setRawMode = vi.fn();
347
+ try {
348
+ const calls = vi.mocked(useInput).mock.calls;
349
+ const handler = calls[calls.length - 1]?.[0];
350
+ expect(handler).toBeDefined();
351
+ handler(' ', makeKey());
352
+ }
353
+ finally {
354
+ process.stdin.setRawMode = origSetRawMode;
355
+ }
356
+ expect(onMenuAction).toHaveBeenCalledWith({
357
+ type: 'sessionActions',
358
+ worktree: sessionlessWorktree,
359
+ session: undefined,
324
360
  });
325
361
  });
326
362
  });
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import type { SessionRecord } from '../services/sessionRestoreStore.js';
3
+ interface RestoreSessionsProps {
4
+ sessions: SessionRecord[];
5
+ /** Whether to show which project each session belongs to. */
6
+ showProject?: boolean;
7
+ onRestore: () => void;
8
+ onDiscard: () => void;
9
+ }
10
+ /**
11
+ * Startup offer to launch the sessions that were open when ccmanager last ran.
12
+ *
13
+ * Restoring runs each session's command preset in its worktree again; the
14
+ * previous terminal output and conversation are not brought back. Declining
15
+ * forgets the listed sessions, so the offer does not reappear on the next
16
+ * start.
17
+ */
18
+ declare const RestoreSessions: React.FC<RestoreSessionsProps>;
19
+ export default RestoreSessions;
@@ -0,0 +1,28 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import path from 'path';
4
+ import Confirmation from './Confirmation.js';
5
+ import { describeRecordPreset } from '../services/sessionRestorer.js';
6
+ /**
7
+ * Startup offer to launch the sessions that were open when ccmanager last ran.
8
+ *
9
+ * Restoring runs each session's command preset in its worktree again; the
10
+ * previous terminal output and conversation are not brought back. Declining
11
+ * forgets the listed sessions, so the offer does not reappear on the next
12
+ * start.
13
+ */
14
+ const RestoreSessions = ({ sessions, showProject = false, onRestore, onDiscard, }) => {
15
+ const message = (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Found ", sessions.length, " session", sessions.length === 1 ? '' : 's', " from the last time ccmanager ran. Start", ' ', sessions.length === 1 ? 'it' : 'them', " again?"] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: sessions.map(session => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "green", children: path.basename(session.worktreePath) }), session.sessionName ? ` (${session.sessionName})` : '', _jsxs(Text, { dimColor: true, children: [' ', "\u2014 ", describeRecordPreset(session), showProject ? ` — ${path.basename(session.projectPath)}` : ''] })] }, session.id))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Each session runs its command again in its worktree. Previous output and conversation are not restored." }) })] }));
16
+ return (_jsx(Confirmation, { title: _jsx(Text, { bold: true, children: "Restore previous sessions" }), message: message, options: [
17
+ { label: 'Restore', value: 'restore', color: 'green' },
18
+ { label: "Don't restore", value: 'discard', color: 'red' },
19
+ ], onSelect: value => {
20
+ if (value === 'restore') {
21
+ onRestore();
22
+ }
23
+ else {
24
+ onDiscard();
25
+ }
26
+ }, hint: _jsx(Text, { dimColor: true, children: "Use \u2191\u2193 to navigate, Enter to select. Declining forgets these sessions." }) }));
27
+ };
28
+ export default RestoreSessions;
@@ -1,8 +1,23 @@
1
1
  import React from 'react';
2
- export type SessionActionType = 'newSession' | 'rename' | 'kill';
2
+ export type SessionActionType = 'newSession' | 'rename' | 'kill' | 'deleteWorktree';
3
3
  interface SessionActionsProps {
4
- sessionLabel: string;
4
+ /**
5
+ * Name of the session this menu was opened from. Absent for a worktree row
6
+ * that has no session yet.
7
+ */
8
+ sessionLabel?: string;
5
9
  worktreePath: string;
10
+ /**
11
+ * Whether the row this menu was opened from has a running session. Session
12
+ * specific actions (rename, close) are hidden when it does not.
13
+ */
14
+ hasSession?: boolean;
15
+ /**
16
+ * Whether the worktree of this row may be deleted; see isDeletableWorktree.
17
+ * The delete entry is hidden rather than shown-and-rejected so no
18
+ * unselectable option appears.
19
+ */
20
+ canDeleteWorktree?: boolean;
6
21
  onSelect: (action: SessionActionType) => void;
7
22
  onCancel: () => void;
8
23
  }
@@ -1,29 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
- const items = [
5
- { label: 'S New session in same directory', value: 'newSession' },
6
- { label: 'R Rename this session', value: 'rename' },
7
- { label: 'X Close session', value: 'kill' },
8
- ];
9
- const SessionActions = ({ sessionLabel, worktreePath, onSelect, onCancel, }) => {
4
+ const buildItems = (hasSession, canDeleteWorktree) => {
5
+ const items = [
6
+ { label: 'S New session in this worktree', value: 'newSession' },
7
+ ];
8
+ if (hasSession) {
9
+ items.push({ label: 'R Rename this session', value: 'rename' });
10
+ items.push({ label: 'X Close session', value: 'kill' });
11
+ }
12
+ if (canDeleteWorktree) {
13
+ items.push({ label: 'D Delete this worktree', value: 'deleteWorktree' });
14
+ }
15
+ return items;
16
+ };
17
+ const SessionActions = ({ sessionLabel, worktreePath, hasSession = true, canDeleteWorktree = false, onSelect, onCancel, }) => {
18
+ const items = buildItems(hasSession, canDeleteWorktree);
10
19
  useInput((input, key) => {
11
20
  if (key.escape) {
12
21
  onCancel();
13
22
  return;
14
23
  }
15
- switch (input.toLowerCase()) {
16
- case 's':
17
- onSelect('newSession');
18
- break;
19
- case 'r':
20
- onSelect('rename');
21
- break;
22
- case 'x':
23
- onSelect('kill');
24
- break;
24
+ const shortcut = items.find(item => item.label[0]?.toLowerCase() === input.toLowerCase());
25
+ if (shortcut) {
26
+ onSelect(shortcut.value);
25
27
  }
26
28
  });
27
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Session Actions" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: sessionLabel }), _jsxs(Text, { dimColor: true, children: ["Directory: ", worktreePath] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "S/R/X or arrow keys + Enter | Escape to cancel" }) })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: hasSession ? 'Session Actions' : 'Worktree Actions' }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [sessionLabel && _jsx(Text, { dimColor: true, children: sessionLabel }), _jsxs(Text, { dimColor: true, children: ["Directory: ", worktreePath] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [items.map(item => item.label[0]).join('/'), " or arrow keys + Enter | Escape to cancel"] }) })] }));
28
30
  };
29
31
  export default SessionActions;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { useInput } from 'ink';
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+ import SessionActions from './SessionActions.js';
6
+ const makeKey = (overrides = {}) => ({
7
+ upArrow: false,
8
+ downArrow: false,
9
+ leftArrow: false,
10
+ rightArrow: false,
11
+ pageDown: false,
12
+ pageUp: false,
13
+ home: false,
14
+ end: false,
15
+ return: false,
16
+ escape: false,
17
+ ctrl: false,
18
+ shift: false,
19
+ tab: false,
20
+ backspace: false,
21
+ delete: false,
22
+ meta: false,
23
+ ...overrides,
24
+ });
25
+ // Mock ink to avoid stdin issues and to capture the hotkey handler
26
+ vi.mock('ink', async () => {
27
+ const actual = await vi.importActual('ink');
28
+ return {
29
+ ...actual,
30
+ useInput: vi.fn(),
31
+ };
32
+ });
33
+ // Mock SelectInput to render items as simple text
34
+ vi.mock('ink-select-input', async () => {
35
+ const React = await vi.importActual('react');
36
+ const { Text, Box } = await vi.importActual('ink');
37
+ return {
38
+ default: ({ items }) => React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label))),
39
+ };
40
+ });
41
+ const getLastInputHandler = () => {
42
+ const calls = vi.mocked(useInput).mock.calls;
43
+ const handler = calls[calls.length - 1]?.[0];
44
+ expect(handler).toBeDefined();
45
+ return handler;
46
+ };
47
+ describe('SessionActions', () => {
48
+ beforeEach(() => {
49
+ vi.mocked(useInput).mockClear();
50
+ });
51
+ it('should show session actions and the delete entry for a deletable worktree', () => {
52
+ const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo/worktrees/feature", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
53
+ const frame = lastFrame();
54
+ expect(frame).toContain('Session Actions');
55
+ expect(frame).toContain('New session in this worktree');
56
+ expect(frame).toContain('Rename this session');
57
+ expect(frame).toContain('Close session');
58
+ expect(frame).toContain('Delete this worktree');
59
+ });
60
+ it('should hide session-specific actions for a worktree without a session', () => {
61
+ const { lastFrame } = render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
62
+ const frame = lastFrame();
63
+ expect(frame).toContain('Worktree Actions');
64
+ expect(frame).toContain('New session in this worktree');
65
+ expect(frame).toContain('Delete this worktree');
66
+ expect(frame).not.toContain('Rename this session');
67
+ expect(frame).not.toContain('Close session');
68
+ });
69
+ it('should hide the delete entry when the worktree cannot be deleted', () => {
70
+ const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: vi.fn(), onCancel: vi.fn() }));
71
+ expect(lastFrame()).not.toContain('Delete this worktree');
72
+ });
73
+ it('should dispatch deleteWorktree on the D hotkey when deletion is offered', () => {
74
+ const onSelect = vi.fn();
75
+ render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: onSelect, onCancel: vi.fn() }));
76
+ getLastInputHandler()('d', makeKey());
77
+ expect(onSelect).toHaveBeenCalledWith('deleteWorktree');
78
+ });
79
+ it('should ignore hotkeys of actions that are not offered', () => {
80
+ const onSelect = vi.fn();
81
+ render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: onSelect, onCancel: vi.fn() }));
82
+ const handler = getLastInputHandler();
83
+ handler('d', makeKey());
84
+ expect(onSelect).not.toHaveBeenCalled();
85
+ handler('x', makeKey());
86
+ expect(onSelect).toHaveBeenCalledWith('kill');
87
+ });
88
+ it('should cancel on Escape', () => {
89
+ const onCancel = vi.fn();
90
+ render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: onCancel }));
91
+ getLastInputHandler()('', makeKey({ escape: true }));
92
+ expect(onCancel).toHaveBeenCalled();
93
+ });
94
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Number of terminal columns a worktree/session row label may occupy.
3
+ * Passed to calculateColumnPositions, which drops the aligned session-state
4
+ * column when the resulting layout would not fit in it.
5
+ */
6
+ export declare function useAvailableLabelWidth(): number;
@@ -0,0 +1,15 @@
1
+ import { useStdout } from 'ink';
2
+ // Columns a list row spends before the assembled label: the SelectInput
3
+ // indicator ("❯ " or two spaces) plus the number prefix ("0 ❯ " / " ❯ ").
4
+ const ROW_PREFIX_WIDTH = 6;
5
+ // Terminal width assumed when stdout reports none (e.g. a non-TTY test stream).
6
+ const FALLBACK_TERMINAL_WIDTH = 80;
7
+ /**
8
+ * Number of terminal columns a worktree/session row label may occupy.
9
+ * Passed to calculateColumnPositions, which drops the aligned session-state
10
+ * column when the resulting layout would not fit in it.
11
+ */
12
+ export function useAvailableLabelWidth() {
13
+ const { stdout } = useStdout();
14
+ return Math.max(0, (stdout.columns || FALLBACK_TERMINAL_WIDTH) - ROW_PREFIX_WIDTH);
15
+ }
@@ -3,27 +3,18 @@
3
3
  * This module is for internal use within the config directory only.
4
4
  * External code should use ConfigEditor or ConfigReader instead.
5
5
  */
6
- import { homedir } from 'os';
7
6
  import { join } from 'path';
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
7
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
9
8
  import { DEFAULT_SHORTCUTS, } from '../../types/index.js';
10
9
  import { DEFAULT_TIMEOUT_SECONDS } from '../../constants/autoApproval.js';
10
+ import { ensureConfigDir } from '../../utils/configDir.js';
11
11
  class GlobalConfigManager {
12
12
  configPath;
13
13
  legacyShortcutsPath;
14
14
  configDir;
15
15
  config = {};
16
16
  constructor() {
17
- // Determine config directory based on platform
18
- const homeDir = homedir();
19
- this.configDir =
20
- process.platform === 'win32'
21
- ? join(process.env['APPDATA'] || join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
22
- : join(homeDir, '.config', 'ccmanager');
23
- // Ensure config directory exists
24
- if (!existsSync(this.configDir)) {
25
- mkdirSync(this.configDir, { recursive: true });
26
- }
17
+ this.configDir = ensureConfigDir();
27
18
  this.configPath = join(this.configDir, 'config.json');
28
19
  this.legacyShortcutsPath = join(this.configDir, 'shortcuts.json');
29
20
  this.loadConfig();
@@ -12,6 +12,13 @@ declare class GlobalSessionOrchestrator {
12
12
  destroyProjectSessions(projectPath: string): void;
13
13
  getProjectPaths(): string[];
14
14
  getProjectSessions(projectPath: string): Session[];
15
+ /**
16
+ * Keep the durable session record in step with one manager's sessions, so a
17
+ * later ccmanager run can offer to launch them again. This orchestrator is
18
+ * the only place that knows which project a manager belongs to, which is why
19
+ * the wiring lives here rather than inside SessionManager.
20
+ */
21
+ private trackSessionsForRestore;
15
22
  }
16
23
  export declare const globalSessionOrchestrator: GlobalSessionOrchestrator;
17
24
  export {};
@@ -1,4 +1,6 @@
1
1
  import { SessionManager } from './sessionManager.js';
2
+ import { getCurrentRepositoryRoot } from '../utils/gitUtils.js';
3
+ import { sessionRestoreStore } from './sessionRestoreStore.js';
2
4
  class GlobalSessionOrchestrator {
3
5
  static instance;
4
6
  projectManagers = new Map();
@@ -6,6 +8,7 @@ class GlobalSessionOrchestrator {
6
8
  constructor() {
7
9
  // Create a global session manager for single-project mode
8
10
  this.globalManager = new SessionManager();
11
+ this.trackSessionsForRestore(this.globalManager);
9
12
  }
10
13
  static getInstance() {
11
14
  if (!GlobalSessionOrchestrator.instance) {
@@ -22,6 +25,7 @@ class GlobalSessionOrchestrator {
22
25
  let manager = this.projectManagers.get(projectPath);
23
26
  if (!manager) {
24
27
  manager = new SessionManager();
28
+ this.trackSessionsForRestore(manager, projectPath);
25
29
  this.projectManagers.set(projectPath, manager);
26
30
  }
27
31
  return manager;
@@ -37,6 +41,11 @@ class GlobalSessionOrchestrator {
37
41
  return sessions;
38
42
  }
39
43
  destroyAllSessions() {
44
+ // Every caller of this method is quitting ccmanager, and the sessions
45
+ // being torn down here are exactly the ones the next run should offer to
46
+ // restore. Stop updating the durable record first so the teardown does
47
+ // not erase them.
48
+ sessionRestoreStore.suspendTracking();
40
49
  // Destroy sessions in global manager
41
50
  this.globalManager.destroy();
42
51
  // Destroy sessions in all project managers
@@ -63,5 +72,36 @@ class GlobalSessionOrchestrator {
63
72
  }
64
73
  return [];
65
74
  }
75
+ /**
76
+ * Keep the durable session record in step with one manager's sessions, so a
77
+ * later ccmanager run can offer to launch them again. This orchestrator is
78
+ * the only place that knows which project a manager belongs to, which is why
79
+ * the wiring lives here rather than inside SessionManager.
80
+ */
81
+ trackSessionsForRestore(manager, projectPath) {
82
+ manager.on('sessionCreated', (session) => {
83
+ sessionRestoreStore.record({
84
+ id: session.id,
85
+ // The global manager has no project path of its own: its sessions
86
+ // belong to the repository ccmanager was started in.
87
+ projectPath: projectPath ?? getCurrentRepositoryRoot(),
88
+ worktreePath: session.worktreePath,
89
+ presetId: session.presetId,
90
+ sessionName: session.sessionName,
91
+ devcontainerConfig: session.devcontainerConfig,
92
+ ownerPid: process.pid,
93
+ createdAt: Date.now(),
94
+ });
95
+ });
96
+ manager.on('sessionRenamed', (session) => {
97
+ sessionRestoreStore.rename(session.id, session.sessionName);
98
+ });
99
+ // A destroyed session is one that should stay gone: either the user
100
+ // killed it or the launched command exited on its own. The exception is
101
+ // the teardown on quit, which suspends tracking beforehand.
102
+ manager.on('sessionDestroyed', (session) => {
103
+ sessionRestoreStore.forget(session.id);
104
+ });
105
+ }
66
106
  }
67
107
  export const globalSessionOrchestrator = GlobalSessionOrchestrator.getInstance();
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { EventEmitter } from 'events';
3
+ const recordMock = vi.fn();
4
+ const forgetMock = vi.fn();
5
+ const renameMock = vi.fn();
6
+ const suspendTrackingMock = vi.fn();
7
+ vi.mock('./sessionRestoreStore.js', () => ({
8
+ sessionRestoreStore: {
9
+ record: (...args) => recordMock(...args),
10
+ forget: (...args) => forgetMock(...args),
11
+ rename: (...args) => renameMock(...args),
12
+ suspendTracking: () => suspendTrackingMock(),
13
+ },
14
+ }));
15
+ vi.mock('../utils/gitUtils.js', () => ({
16
+ getCurrentRepositoryRoot: () => '/current/repo',
17
+ }));
18
+ class MockSessionManager extends EventEmitter {
19
+ getAllSessions() {
20
+ return [];
21
+ }
22
+ destroy() { }
23
+ }
24
+ vi.mock('./sessionManager.js', () => ({
25
+ SessionManager: MockSessionManager,
26
+ }));
27
+ const { globalSessionOrchestrator } = await import('./globalSessionOrchestrator.js');
28
+ const session = (overrides = {}) => ({
29
+ id: 'session-1',
30
+ worktreePath: '/repo/worktrees/feature',
31
+ presetId: 'preset-1',
32
+ sessionName: undefined,
33
+ devcontainerConfig: undefined,
34
+ ...overrides,
35
+ });
36
+ describe('GlobalSessionOrchestrator session record', () => {
37
+ beforeEach(() => {
38
+ recordMock.mockClear();
39
+ forgetMock.mockClear();
40
+ renameMock.mockClear();
41
+ suspendTrackingMock.mockClear();
42
+ });
43
+ it('records a session of the current repository when there is no project path', () => {
44
+ const manager = globalSessionOrchestrator.getManagerForProject();
45
+ manager.emit('sessionCreated', session());
46
+ expect(recordMock).toHaveBeenCalledWith(expect.objectContaining({
47
+ id: 'session-1',
48
+ projectPath: '/current/repo',
49
+ worktreePath: '/repo/worktrees/feature',
50
+ presetId: 'preset-1',
51
+ ownerPid: process.pid,
52
+ }));
53
+ });
54
+ it('records a session under the project its manager belongs to', () => {
55
+ const manager = globalSessionOrchestrator.getManagerForProject('/other/project');
56
+ manager.emit('sessionCreated', session({ id: 'session-2' }));
57
+ expect(recordMock).toHaveBeenCalledWith(expect.objectContaining({
58
+ id: 'session-2',
59
+ projectPath: '/other/project',
60
+ }));
61
+ });
62
+ it('forgets a session that was killed or exited', () => {
63
+ const manager = globalSessionOrchestrator.getManagerForProject();
64
+ manager.emit('sessionDestroyed', session());
65
+ expect(forgetMock).toHaveBeenCalledWith('session-1');
66
+ });
67
+ it('keeps the record in step with a rename', () => {
68
+ const manager = globalSessionOrchestrator.getManagerForProject();
69
+ manager.emit('sessionRenamed', session({ sessionName: 'review' }));
70
+ expect(renameMock).toHaveBeenCalledWith('session-1', 'review');
71
+ });
72
+ it('stops tracking before quitting, so the sessions stay restorable', () => {
73
+ globalSessionOrchestrator.destroyAllSessions();
74
+ expect(suspendTrackingMock).toHaveBeenCalled();
75
+ });
76
+ });
@@ -2,10 +2,10 @@ import { WorktreeService } from './worktreeService.js';
2
2
  import { ENV_VARS } from '../constants/env.js';
3
3
  import { promises as fs } from 'fs';
4
4
  import path from 'path';
5
- import { homedir } from 'os';
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
7
6
  import { Effect } from 'effect';
8
7
  import { FileSystemError, ConfigError } from '../types/errors.js';
8
+ import { ensureConfigDir } from '../utils/configDir.js';
9
9
  export class ProjectManager {
10
10
  currentMode;
11
11
  currentProject;
@@ -31,15 +31,7 @@ export class ProjectManager {
31
31
  this.currentMode = 'normal';
32
32
  }
33
33
  // Initialize recent projects
34
- const homeDir = homedir();
35
- this.configDir =
36
- process.platform === 'win32'
37
- ? path.join(process.env['APPDATA'] || path.join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
38
- : path.join(homeDir, '.config', 'ccmanager');
39
- // Ensure config directory exists
40
- if (!existsSync(this.configDir)) {
41
- mkdirSync(this.configDir, { recursive: true });
42
- }
34
+ this.configDir = ensureConfigDir();
43
35
  this.dataPath = path.join(this.configDir, 'recent-projects.json');
44
36
  this.loadRecentProjects();
45
37
  }
@@ -83,6 +83,12 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
83
83
  private setupBackgroundHandler;
84
84
  private cleanupSession;
85
85
  getSessionById(id: string): Session | undefined;
86
+ /**
87
+ * Assign a user-facing name to a session. Goes through the manager rather
88
+ * than mutating the session object directly so that listeners (currently the
89
+ * restore record) learn about the new name.
90
+ */
91
+ renameSession(sessionId: string, sessionName?: string): void;
86
92
  getSessionsForWorktree(worktreePath: string): Session[];
87
93
  setSessionActive(sessionId: string, active: boolean): void;
88
94
  private emitRestoreSnapshot;
@@ -342,6 +342,7 @@ export class SessionManager extends EventEmitter {
342
342
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
343
343
  isPrimaryCommand: options.isPrimaryCommand ?? true,
344
344
  presetName: options.presetName,
345
+ presetId: options.presetId,
345
346
  detectionStrategy,
346
347
  devcontainerConfig: options.devcontainerConfig ?? undefined,
347
348
  stateMutex: new Mutex(createInitialSessionStateData()),
@@ -385,6 +386,7 @@ export class SessionManager extends EventEmitter {
385
386
  command,
386
387
  fallbackArgs: preset.fallbackArgs,
387
388
  presetName: preset.name,
389
+ presetId: preset.id,
388
390
  detectionStrategy: preset.detectionStrategy,
389
391
  });
390
392
  if (launch.stdinPayload) {
@@ -569,6 +571,19 @@ export class SessionManager extends EventEmitter {
569
571
  getSessionById(id) {
570
572
  return this.sessions.get(id);
571
573
  }
574
+ /**
575
+ * Assign a user-facing name to a session. Goes through the manager rather
576
+ * than mutating the session object directly so that listeners (currently the
577
+ * restore record) learn about the new name.
578
+ */
579
+ renameSession(sessionId, sessionName) {
580
+ const session = this.sessions.get(sessionId);
581
+ if (!session) {
582
+ return;
583
+ }
584
+ session.sessionName = sessionName;
585
+ this.emit('sessionRenamed', session);
586
+ }
572
587
  getSessionsForWorktree(worktreePath) {
573
588
  return Array.from(this.sessions.values()).filter(s => s.worktreePath === worktreePath);
574
589
  }
@@ -878,6 +893,7 @@ export class SessionManager extends EventEmitter {
878
893
  command: preset.command,
879
894
  fallbackArgs: preset.fallbackArgs,
880
895
  presetName: preset.name,
896
+ presetId: preset.id,
881
897
  detectionStrategy: preset.detectionStrategy,
882
898
  devcontainerConfig,
883
899
  });
@@ -0,0 +1,75 @@
1
+ import { DevcontainerConfig } from '../types/index.js';
2
+ /** Format version of the on-disk file, so future changes can be detected. */
3
+ export declare const SESSION_RECORD_VERSION = 1;
4
+ export declare const SESSION_RECORD_FILE_NAME = "sessions.json";
5
+ /** A single session, described well enough to launch it again. */
6
+ export interface SessionRecord {
7
+ /** Id of the in-memory session this record was written for. */
8
+ id: string;
9
+ /**
10
+ * Git repository root the session belongs to. Used to only offer sessions
11
+ * of the repository the user is actually opening.
12
+ */
13
+ projectPath: string;
14
+ worktreePath: string;
15
+ /**
16
+ * Id of the command preset the session was launched with. Absent when the
17
+ * preset could not be determined; the default preset is then used on
18
+ * restore. Only the id is stored — the command itself stays owned by the
19
+ * preset configuration.
20
+ */
21
+ presetId?: string;
22
+ /** User-assigned session name, if the user renamed the session. */
23
+ sessionName?: string;
24
+ /** Devcontainer commands the session was launched through, if any. */
25
+ devcontainerConfig?: DevcontainerConfig;
26
+ /**
27
+ * Process id of the ccmanager run that owns this session. A record whose
28
+ * owner process is still running belongs to another live ccmanager and must
29
+ * not be restored, or the same session would end up running twice.
30
+ */
31
+ ownerPid: number;
32
+ createdAt: number;
33
+ }
34
+ export declare class SessionRestoreStore {
35
+ private explicitFilePath?;
36
+ /**
37
+ * While true, mutations are ignored. Set just before ccmanager tears its
38
+ * sessions down on exit: those sessions are exactly the ones the next run
39
+ * should offer to restore, so their records must survive the teardown.
40
+ */
41
+ private trackingSuspended;
42
+ constructor(filePath?: string);
43
+ /**
44
+ * Resolved lazily rather than in the constructor so that merely importing
45
+ * this module does not create the configuration directory.
46
+ */
47
+ private get filePath();
48
+ /** All recorded sessions, including ones owned by other ccmanager runs. */
49
+ list(): SessionRecord[];
50
+ /** Add a session to the record, replacing any earlier record with the same id. */
51
+ record(session: SessionRecord): void;
52
+ /**
53
+ * Drop sessions from the record, so they are not offered on the next run.
54
+ * Called when a session ends for a reason that means it should stay ended:
55
+ * the user killed it, or the launched command exited by itself.
56
+ */
57
+ forget(...ids: string[]): void;
58
+ /**
59
+ * Keep a renamed session's name in the record. An absent name means the
60
+ * user cleared it.
61
+ */
62
+ rename(id: string, sessionName?: string): void;
63
+ /**
64
+ * Stop applying further mutations. Used when ccmanager is shutting down and
65
+ * destroys its sessions: without this the teardown would erase precisely the
66
+ * records the next run needs.
67
+ */
68
+ suspendTracking(): void;
69
+ /** Resume applying mutations. Counterpart of {@link suspendTracking}. */
70
+ resumeTracking(): void;
71
+ isTrackingSuspended(): boolean;
72
+ private read;
73
+ private write;
74
+ }
75
+ export declare const sessionRestoreStore: SessionRestoreStore;