ccmanager 4.3.2 → 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 (38) hide show
  1. package/README.md +42 -0
  2. package/dist/components/App.js +46 -21
  3. package/dist/components/App.test.js +86 -0
  4. package/dist/components/Dashboard.js +10 -3
  5. package/dist/components/Menu.js +6 -1
  6. package/dist/components/RestoreSessions.d.ts +19 -0
  7. package/dist/components/RestoreSessions.js +28 -0
  8. package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
  9. package/dist/hooks/useAvailableLabelWidth.js +15 -0
  10. package/dist/services/config/globalConfigManager.js +3 -12
  11. package/dist/services/globalSessionOrchestrator.d.ts +7 -0
  12. package/dist/services/globalSessionOrchestrator.js +40 -0
  13. package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
  14. package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
  15. package/dist/services/projectManager.js +3 -11
  16. package/dist/services/sessionManager.d.ts +6 -0
  17. package/dist/services/sessionManager.js +16 -0
  18. package/dist/services/sessionRestoreStore.d.ts +75 -0
  19. package/dist/services/sessionRestoreStore.js +138 -0
  20. package/dist/services/sessionRestoreStore.test.d.ts +1 -0
  21. package/dist/services/sessionRestoreStore.test.js +92 -0
  22. package/dist/services/sessionRestorer.d.ts +46 -0
  23. package/dist/services/sessionRestorer.js +123 -0
  24. package/dist/services/sessionRestorer.test.d.ts +1 -0
  25. package/dist/services/sessionRestorer.test.js +163 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/utils/configDir.d.ts +10 -0
  28. package/dist/utils/configDir.js +31 -0
  29. package/dist/utils/errorMessage.d.ts +7 -0
  30. package/dist/utils/errorMessage.js +19 -0
  31. package/dist/utils/filterByQuery.test.js +2 -0
  32. package/dist/utils/gitUtils.d.ts +1 -0
  33. package/dist/utils/gitUtils.js +15 -0
  34. package/dist/utils/hookExecutor.test.js +4 -0
  35. package/dist/utils/worktreeUtils.d.ts +25 -4
  36. package/dist/utils/worktreeUtils.js +53 -17
  37. package/dist/utils/worktreeUtils.test.js +62 -4
  38. package/package.json +6 -6
package/README.md CHANGED
@@ -14,6 +14,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
14
14
  - Switch between sessions seamlessly
15
15
  - Visual status indicators for session states (busy, waiting, idle)
16
16
  - Create, merge, and delete worktrees from within the app
17
+ - **Restore sessions after a restart**: reopen the sessions that were running when CCManager last exited or crashed
17
18
  - **Copy Claude Code session data** between worktrees to maintain conversation context
18
19
  - **`.worktreeinclude` support**: carry gitignored project files (`.env`, local certs) into newly created worktrees
19
20
  - Configurable keyboard shortcuts
@@ -117,6 +118,22 @@ CCManager supports per-project configuration by placing a `.ccmanager.json` file
117
118
 
118
119
  For detailed configuration options and examples, see [docs/project-config.md](docs/project-config.md).
119
120
 
121
+ ### Configuring with an AI coding agent
122
+
123
+ This repository doubles as a plugin marketplace providing the **`ccmanager-config`** skill, which teaches Claude Code or Codex the whole configuration schema and ships a validator for it:
124
+
125
+ ```bash
126
+ # Claude Code
127
+ claude plugin marketplace add kbwo/ccmanager
128
+ claude plugin install ccmanager-config@ccmanager
129
+
130
+ # Codex CLI
131
+ codex plugin marketplace add kbwo/ccmanager
132
+ codex plugin add ccmanager-config@ccmanager
133
+ ```
134
+
135
+ Then just ask — "set this repo up to run codex in ccmanager", "notify me when a session is waiting for input", "why is my `.ccmanager.json` being ignored?". See [plugins/ccmanager-config/README.md](plugins/ccmanager-config/README.md).
136
+
120
137
  ## Supported AI Assistants
121
138
 
122
139
  CCManager supports multiple AI coding assistants with tailored state detection for each:
@@ -221,6 +238,31 @@ A new Git worktree contains only tracked files, so gitignored files a project ne
221
238
 
222
239
  For pattern syntax, the exact selection rule, and troubleshooting, see [docs/worktree-include.md](docs/worktree-include.md).
223
240
 
241
+ ## Restoring Sessions After a Restart
242
+
243
+ Closing CCManager kills the AI assistant processes it started, so reopening it normally means finding each worktree again and starting each session by hand. To avoid that, CCManager keeps a record of the sessions it currently has open, and on the next start offers to launch them again:
244
+
245
+ ```
246
+ Restore previous sessions
247
+
248
+ Found 2 sessions from the last time ccmanager ran. Start them again?
249
+
250
+ feature-login — Main
251
+ fix-timeout (review) — Codex
252
+
253
+ > Restore
254
+ Don't restore
255
+ ```
256
+
257
+ - **What is restored**: each session's command preset is run again in its worktree, under the name you had given it. The previous terminal output and the conversation held inside the assistant are *not* restored — restoring means starting the same command again, not resuming where it left off. (To carry conversation context into a *new* worktree, see [Session Data Copying](#session-data-copying).)
258
+ - **Survives a crash**: the record is written as sessions come and go, not on exit, so a crash, a `kill -9`, or a closed terminal leaves it intact.
259
+ - **Only sessions that were still open**: a session you killed, or one whose command exited on its own, is dropped from the record and is not offered.
260
+ - **Declining is remembered**: choosing "Don't restore" forgets those sessions, so the offer does not come back on the next start.
261
+ - **Scope**: normally only the repository you are opening is considered. In [Multi-Project Mode](#multi-project-mode) the sessions of every recorded project are offered together at startup.
262
+ - **Other running instances**: sessions belonging to another CCManager that is still open are left alone, so they are not started a second time.
263
+
264
+ The record lives in `~/.config/ccmanager/sessions.json` (`%APPDATA%\ccmanager\sessions.json` on Windows).
265
+
224
266
  ## Status Change Hooks
225
267
 
226
268
  CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback } from 'react';
3
3
  import { useApp, useInput, Box, Text } from 'ink';
4
4
  import { Effect } from 'effect';
@@ -15,10 +15,14 @@ import RemoteBranchSelector from './RemoteBranchSelector.js';
15
15
  import LoadingSpinner from './LoadingSpinner.js';
16
16
  import SessionRename from './SessionRename.js';
17
17
  import SessionActions from './SessionActions.js';
18
+ import RestoreSessions from './RestoreSessions.js';
18
19
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
19
20
  import { WorktreeService } from '../services/worktreeService.js';
20
21
  import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
21
22
  import { logger } from '../utils/logger.js';
23
+ import { formatErrorMessage } from '../utils/errorMessage.js';
24
+ import { getCurrentRepositoryRoot } from '../utils/gitUtils.js';
25
+ import { discardRestorableSessions, listRestorableSessions, restoreSessions, } from '../services/sessionRestorer.js';
22
26
  import { configReader } from '../services/config/configReader.js';
23
27
  import { ENV_VARS } from '../constants/env.js';
24
28
  import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
@@ -26,9 +30,17 @@ import { projectManager } from '../services/projectManager.js';
26
30
  import { generateWorktreeDirectory, isDeletableWorktree, } from '../utils/worktreeUtils.js';
27
31
  const App = ({ devcontainerConfig, multiProject, version, }) => {
28
32
  const { exit } = useApp();
29
- const [view, setView] = useState(multiProject ? 'project-list' : 'menu');
30
33
  const [sessionManager, setSessionManager] = useState(() => globalSessionOrchestrator.getManagerForProject());
31
34
  const [worktreeService, setWorktreeService] = useState(() => new WorktreeService());
35
+ // Sessions that were open when ccmanager last ran and can be started again.
36
+ // Single-project mode only considers the repository being opened;
37
+ // multi-project mode considers every recorded project at once.
38
+ const [restorableSessions, setRestorableSessions] = useState(() => listRestorableSessions(multiProject ? {} : { projectPath: getCurrentRepositoryRoot() }));
39
+ const [view, setView] = useState(() => restorableSessions.length > 0
40
+ ? 'restore-sessions'
41
+ : multiProject
42
+ ? 'project-list'
43
+ : 'menu');
32
44
  const [activeSession, setActiveSession] = useState(null);
33
45
  const [error, setError] = useState(null);
34
46
  const [worktreeHookError, setWorktreeHookError] = useState(null);
@@ -87,21 +99,6 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
87
99
  setWorktreeHookError(null);
88
100
  handleReturnToMenu();
89
101
  }, { isActive: view === 'worktree-hook-error' });
90
- // Helper function to format error messages based on error type using _tag discrimination
91
- const formatErrorMessage = (error) => {
92
- switch (error._tag) {
93
- case 'ProcessError':
94
- return `Process error: ${error.message}`;
95
- case 'ConfigError':
96
- return `Configuration error (${error.reason}): ${error.details}`;
97
- case 'GitError':
98
- return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
99
- case 'FileSystemError':
100
- return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
101
- case 'ValidationError':
102
- return `Validation failed for ${error.field}: ${error.constraint}`;
103
- }
104
- };
105
102
  const formatPostCreationHookWarning = (error) => `Post-creation hook failed: ${error.message}`;
106
103
  const formatPreCreationHookError = (error) => error._tag === 'ProcessError'
107
104
  ? `Pre-creation hook failed: ${error.message}`
@@ -155,6 +152,31 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
155
152
  setView('session');
156
153
  }, 10);
157
154
  }, []);
155
+ // The view the app starts on once the restore offer is out of the way.
156
+ const initialView = multiProject ? 'project-list' : 'menu';
157
+ const handleRestorePreviousSessions = useCallback(() => {
158
+ const records = restorableSessions;
159
+ setRestorableSessions([]);
160
+ setView('restoring-sessions');
161
+ void (async () => {
162
+ const outcome = await restoreSessions(records, {
163
+ multiProject: !!multiProject,
164
+ });
165
+ if (outcome.failures.length > 0) {
166
+ setError(`Could not restore ${outcome.failures.length} of ${records.length} sessions: ${outcome.failures
167
+ .map(failure => `${failure.record.worktreePath} (${failure.message})`)
168
+ .join(', ')}`);
169
+ }
170
+ navigateWithClear(initialView, () => {
171
+ setMenuKey(prev => prev + 1);
172
+ });
173
+ })();
174
+ }, [restorableSessions, multiProject, initialView, navigateWithClear]);
175
+ const handleDiscardPreviousSessions = useCallback(() => {
176
+ discardRestorableSessions(restorableSessions);
177
+ setRestorableSessions([]);
178
+ navigateWithClear(initialView);
179
+ }, [restorableSessions, initialView, navigateWithClear]);
158
180
  const startSessionForWorktree = useCallback(async (worktree, options) => {
159
181
  // If a specific session is provided, navigate to it directly
160
182
  if (options?.session) {
@@ -619,6 +641,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
619
641
  setMenuKey(prev => prev + 1);
620
642
  });
621
643
  };
644
+ if (view === 'restore-sessions') {
645
+ return (_jsx(RestoreSessions, { sessions: restorableSessions, showProject: multiProject, onRestore: handleRestorePreviousSessions, onDiscard: handleDiscardPreviousSessions }));
646
+ }
647
+ if (view === 'restoring-sessions') {
648
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: "Restoring previous sessions...", color: "cyan" }) }));
649
+ }
622
650
  if (view === 'project-list' && multiProject) {
623
651
  const projectsDir = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
624
652
  if (!projectsDir) {
@@ -667,10 +695,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
667
695
  }
668
696
  if (view === 'rename-session' && renameTarget) {
669
697
  return (_jsx(SessionRename, { currentName: renameTarget.name, onRename: name => {
670
- const session = sessionManager.getSessionById(renameTarget.id);
671
- if (session) {
672
- session.sessionName = name;
673
- }
698
+ sessionManager.renameSession(renameTarget.id, name);
674
699
  setRenameTarget(null);
675
700
  handleReturnToMenu();
676
701
  }, onCancel: () => {
@@ -37,6 +37,20 @@ const configReaderMock = {
37
37
  const projectManagerMock = {
38
38
  addRecentProject: vi.fn(),
39
39
  };
40
+ const listRestorableSessionsMock = vi.fn((_options) => []);
41
+ const restoreSessionsMock = vi.fn(async (_records, _options) => ({
42
+ restored: 0,
43
+ failures: [],
44
+ }));
45
+ const discardRestorableSessionsMock = vi.fn((_records) => { });
46
+ const createSessionRecord = (overrides = {}) => ({
47
+ id: 'record-1',
48
+ projectPath: '/repo',
49
+ worktreePath: '/repo/worktrees/feature',
50
+ ownerPid: 4242,
51
+ createdAt: 1,
52
+ ...overrides,
53
+ });
40
54
  const worktreeNameGeneratorMock = {
41
55
  generateBranchNameEffect: vi.fn(() => Effect.succeed('fix/trim-worktree-name')),
42
56
  };
@@ -68,6 +82,15 @@ vi.mock('../services/globalSessionOrchestrator.js', () => ({
68
82
  vi.mock('../services/projectManager.js', () => ({
69
83
  projectManager: projectManagerMock,
70
84
  }));
85
+ vi.mock('../services/sessionRestorer.js', () => ({
86
+ listRestorableSessions: (options) => listRestorableSessionsMock(options),
87
+ restoreSessions: (records, options) => restoreSessionsMock(records, options),
88
+ discardRestorableSessions: (records) => discardRestorableSessionsMock(records),
89
+ describeRecordPreset: () => 'Main',
90
+ }));
91
+ vi.mock('../utils/gitUtils.js', () => ({
92
+ getCurrentRepositoryRoot: () => '/repo',
93
+ }));
71
94
  vi.mock('../services/config/configReader.js', () => ({
72
95
  configReader: configReaderMock,
73
96
  }));
@@ -143,6 +166,11 @@ beforeEach(() => {
143
166
  configReaderMock.getSelectPresetOnStart.mockReset();
144
167
  configReaderMock.getSelectPresetOnStart.mockReturnValue(false);
145
168
  projectManagerMock.addRecentProject.mockReset();
169
+ listRestorableSessionsMock.mockReset();
170
+ listRestorableSessionsMock.mockReturnValue([]);
171
+ restoreSessionsMock.mockReset();
172
+ restoreSessionsMock.mockResolvedValue({ restored: 0, failures: [] });
173
+ discardRestorableSessionsMock.mockReset();
146
174
  worktreeNameGeneratorMock.generateBranchNameEffect.mockReset();
147
175
  worktreeNameGeneratorMock.generateBranchNameEffect.mockImplementation(() => Effect.succeed('fix/trim-worktree-name'));
148
176
  });
@@ -156,6 +184,64 @@ describe('App component view state', () => {
156
184
  expect(lastFrame()).toContain('Menu View');
157
185
  unmount();
158
186
  });
187
+ it('offers to restore the sessions recorded by the previous run', async () => {
188
+ listRestorableSessionsMock.mockReturnValue([
189
+ createSessionRecord({ sessionName: 'review' }),
190
+ ]);
191
+ const { lastFrame, unmount } = render(_jsx(App, { version: "test" }));
192
+ await flush(40);
193
+ expect(listRestorableSessionsMock).toHaveBeenCalledWith({
194
+ projectPath: '/repo',
195
+ });
196
+ expect(lastFrame()).toContain('Restore previous sessions');
197
+ expect(lastFrame()).toContain('feature');
198
+ expect(lastFrame()).toContain('review');
199
+ unmount();
200
+ });
201
+ it('restores the recorded sessions and then shows the menu', async () => {
202
+ const record = createSessionRecord();
203
+ listRestorableSessionsMock.mockReturnValue([record]);
204
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
205
+ await flush(40);
206
+ stdin.write('\r');
207
+ await waitForCondition(() => restoreSessionsMock.mock.calls.length > 0);
208
+ expect(restoreSessionsMock).toHaveBeenCalledWith([record], {
209
+ multiProject: false,
210
+ });
211
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
212
+ unmount();
213
+ });
214
+ it('forgets the recorded sessions when the restore offer is declined', async () => {
215
+ const record = createSessionRecord();
216
+ listRestorableSessionsMock.mockReturnValue([record]);
217
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
218
+ await flush(40);
219
+ // Move from "Restore" to "Don't restore" before confirming.
220
+ stdin.write('\u001B[B');
221
+ await flush(10);
222
+ stdin.write('\r');
223
+ await waitForCondition(() => discardRestorableSessionsMock.mock.calls.length > 0);
224
+ expect(discardRestorableSessionsMock).toHaveBeenCalledWith([record]);
225
+ expect(restoreSessionsMock).not.toHaveBeenCalled();
226
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
227
+ unmount();
228
+ });
229
+ it('considers every recorded project in multi-project mode', async () => {
230
+ const original = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
231
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';
232
+ listRestorableSessionsMock.mockReturnValue([createSessionRecord()]);
233
+ const { lastFrame, unmount } = render(_jsx(App, { multiProject: true, version: "test" }));
234
+ await flush(40);
235
+ expect(listRestorableSessionsMock).toHaveBeenCalledWith({});
236
+ expect(lastFrame()).toContain('Restore previous sessions');
237
+ unmount();
238
+ if (original === undefined) {
239
+ delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
240
+ }
241
+ else {
242
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = original;
243
+ }
244
+ });
159
245
  it('renders the project list view first in multi-project mode', async () => {
160
246
  const original = process.env[ENV_VARS.MULTI_PROJECT_ROOT];
161
247
  process.env[ENV_VARS.MULTI_PROJECT_ROOT] = '/tmp/projects';
@@ -11,6 +11,7 @@ import { WorktreeService } from '../services/worktreeService.js';
11
11
  import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
12
12
  import { useSearchMode } from '../hooks/useSearchMode.js';
13
13
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
14
+ import { useAvailableLabelWidth } from '../hooks/useAvailableLabelWidth.js';
14
15
  import { useGitStatus } from '../hooks/useGitStatus.js';
15
16
  import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, displaySuffix, } from '../utils/worktreeUtils.js';
16
17
  import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
@@ -84,6 +85,9 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
84
85
  isSearchMode,
85
86
  hasError: !!displayError,
86
87
  });
88
+ // Room a row label may occupy; decides whether the session state tag gets
89
+ // its own aligned column or is appended to the branch name instead.
90
+ const availableLabelWidth = useAvailableLabelWidth();
87
91
  // Git status polling for session worktrees
88
92
  const enrichedWorktrees = useGitStatus(baseSessionWorktrees, baseSessionWorktrees.length > 0 ? 'main' : null);
89
93
  // Discover projects on mount
@@ -216,7 +220,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
216
220
  const wt = enrichedWorktrees.find(w => w.path === entry.worktree.path) ||
217
221
  entry.worktree;
218
222
  const stateData = entry.session.stateMutex.getSnapshot();
219
- const status = ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
223
+ const status = `[${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
220
224
  const fullBranchName = wt.branch
221
225
  ? wt.branch.replace('refs/heads/', '')
222
226
  : wt.path.split('/').pop() || 'detached';
@@ -225,7 +229,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
225
229
  const worktreeSessionCount = sessionEntries.filter(e => e.worktree.path === entry.worktree.path &&
226
230
  e.projectPath === entry.projectPath).length;
227
231
  const sessionSuffix = displaySuffix(entry.session, worktreeSessionCount > 1);
228
- const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}${status}`;
232
+ const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}`;
229
233
  let fileChanges = '';
230
234
  let aheadBehind = '';
231
235
  let parentBranch = '';
@@ -245,6 +249,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
245
249
  worktree: wt,
246
250
  session: entry.session,
247
251
  baseLabel,
252
+ status,
248
253
  searchableName: `${entry.projectName} :: ${fullBranchName}${isMain}`,
249
254
  fileChanges,
250
255
  aheadBehind,
@@ -255,6 +260,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
255
260
  error: itemError,
256
261
  lengths: {
257
262
  base: stripAnsi(baseLabel).length,
263
+ status: stripAnsi(status).length,
258
264
  fileChanges: stripAnsi(fileChanges).length,
259
265
  aheadBehind: stripAnsi(aheadBehind).length,
260
266
  parentBranch: stripAnsi(parentBranch).length,
@@ -264,7 +270,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
264
270
  },
265
271
  };
266
272
  });
267
- const columns = calculateColumnPositions(sessionWorkItems);
273
+ const columns = calculateColumnPositions(sessionWorkItems, availableLabelWidth);
268
274
  if (!isSearchMode) {
269
275
  menuItems.push({
270
276
  type: 'common',
@@ -369,6 +375,7 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAct
369
375
  projectDisplayNames,
370
376
  searchQuery,
371
377
  isSearchMode,
378
+ availableLabelWidth,
372
379
  ]);
373
380
  // Refresh handler
374
381
  const refreshAll = () => {
@@ -10,6 +10,7 @@ import { prepareSessionItems, calculateColumnPositions, assembleSessionLabel, }
10
10
  import { projectManager } from '../services/projectManager.js';
11
11
  import { useSearchMode } from '../hooks/useSearchMode.js';
12
12
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
13
+ import { useAvailableLabelWidth } from '../hooks/useAvailableLabelWidth.js';
13
14
  import { filterSessionItemsByQuery, filterSessionItemsByState, cycleSessionStateFilter, getSessionStateFilterLabel, } from '../utils/filterByQuery.js';
14
15
  import SearchableList from './SearchableList.js';
15
16
  import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
@@ -60,6 +61,9 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
60
61
  });
61
62
  // Get worktree configuration for sorting
62
63
  const worktreeConfig = configReader.getWorktreeConfig();
64
+ // Room a row label may occupy; decides whether the session state tag gets
65
+ // its own aligned column or is appended to the branch name instead.
66
+ const availableLabelWidth = useAvailableLabelWidth();
63
67
  useEffect(() => {
64
68
  let cancelled = false;
65
69
  // These operations are independent. Run them concurrently so the initial
@@ -154,7 +158,7 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
154
158
  const items = prepareSessionItems(worktrees, sessions, {
155
159
  sortByLastSession: worktreeConfig.sortByLastSession,
156
160
  });
157
- const columnPositions = calculateColumnPositions(items);
161
+ const columnPositions = calculateColumnPositions(items, availableLabelWidth);
158
162
  // Filter session items based on search query, matching the name shown in
159
163
  // the menu (branch name, " (main)", and session name) plus the path, then
160
164
  // narrow to the selected session state. The two filters are independent
@@ -320,6 +324,7 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
320
324
  autoApprovalToggleCounter,
321
325
  sessionManager,
322
326
  worktreeConfig.sortByLastSession,
327
+ availableLabelWidth,
323
328
  ]);
324
329
  // Handle hotkeys
325
330
  useInput((input, key) => {
@@ -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;
@@ -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();