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
@@ -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;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @fileoverview Durable record of the sessions ccmanager currently has open,
3
+ * so that a later ccmanager run can offer to start them again.
4
+ *
5
+ * Only what is needed to launch the same command again is stored (which
6
+ * worktree, which command preset, the user-assigned session name). The
7
+ * terminal output and the conversation held inside the launched CLI are
8
+ * deliberately not stored: restoring means "run the launch command in that
9
+ * worktree again", not "bring back what was on screen".
10
+ *
11
+ * Every mutation is written to disk immediately and synchronously rather than
12
+ * on shutdown, so a crash, a `kill -9`, or a closed terminal still leaves an
13
+ * accurate record behind.
14
+ *
15
+ * Each mutation also re-reads the file before rewriting it. Two ccmanager
16
+ * processes share this one file, and a read-modify-write keeps one process's
17
+ * write from discarding sessions the other process recorded in the meantime.
18
+ */
19
+ import path from 'path';
20
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs';
21
+ import { ensureConfigDir } from '../utils/configDir.js';
22
+ import { logger } from '../utils/logger.js';
23
+ /** Format version of the on-disk file, so future changes can be detected. */
24
+ export const SESSION_RECORD_VERSION = 1;
25
+ export const SESSION_RECORD_FILE_NAME = 'sessions.json';
26
+ export class SessionRestoreStore {
27
+ explicitFilePath;
28
+ /**
29
+ * While true, mutations are ignored. Set just before ccmanager tears its
30
+ * sessions down on exit: those sessions are exactly the ones the next run
31
+ * should offer to restore, so their records must survive the teardown.
32
+ */
33
+ trackingSuspended = false;
34
+ constructor(filePath) {
35
+ this.explicitFilePath = filePath;
36
+ }
37
+ /**
38
+ * Resolved lazily rather than in the constructor so that merely importing
39
+ * this module does not create the configuration directory.
40
+ */
41
+ get filePath() {
42
+ return (this.explicitFilePath ??
43
+ path.join(ensureConfigDir(), SESSION_RECORD_FILE_NAME));
44
+ }
45
+ /** All recorded sessions, including ones owned by other ccmanager runs. */
46
+ list() {
47
+ return this.read();
48
+ }
49
+ /** Add a session to the record, replacing any earlier record with the same id. */
50
+ record(session) {
51
+ if (this.trackingSuspended) {
52
+ return;
53
+ }
54
+ this.write([...this.read().filter(s => s.id !== session.id), session]);
55
+ }
56
+ /**
57
+ * Drop sessions from the record, so they are not offered on the next run.
58
+ * Called when a session ends for a reason that means it should stay ended:
59
+ * the user killed it, or the launched command exited by itself.
60
+ */
61
+ forget(...ids) {
62
+ if (this.trackingSuspended || ids.length === 0) {
63
+ return;
64
+ }
65
+ const dropped = new Set(ids);
66
+ const remaining = this.read().filter(session => !dropped.has(session.id));
67
+ this.write(remaining);
68
+ }
69
+ /**
70
+ * Keep a renamed session's name in the record. An absent name means the
71
+ * user cleared it.
72
+ */
73
+ rename(id, sessionName) {
74
+ if (this.trackingSuspended) {
75
+ return;
76
+ }
77
+ const sessions = this.read();
78
+ const target = sessions.find(session => session.id === id);
79
+ if (!target) {
80
+ return;
81
+ }
82
+ target.sessionName = sessionName;
83
+ this.write(sessions);
84
+ }
85
+ /**
86
+ * Stop applying further mutations. Used when ccmanager is shutting down and
87
+ * destroys its sessions: without this the teardown would erase precisely the
88
+ * records the next run needs.
89
+ */
90
+ suspendTracking() {
91
+ this.trackingSuspended = true;
92
+ }
93
+ /** Resume applying mutations. Counterpart of {@link suspendTracking}. */
94
+ resumeTracking() {
95
+ this.trackingSuspended = false;
96
+ }
97
+ isTrackingSuspended() {
98
+ return this.trackingSuspended;
99
+ }
100
+ read() {
101
+ if (!existsSync(this.filePath)) {
102
+ return [];
103
+ }
104
+ try {
105
+ const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8'));
106
+ if (!Array.isArray(parsed.sessions)) {
107
+ return [];
108
+ }
109
+ return parsed.sessions.filter(session => typeof session?.id === 'string' &&
110
+ typeof session?.projectPath === 'string' &&
111
+ typeof session?.worktreePath === 'string');
112
+ }
113
+ catch (error) {
114
+ // A truncated or hand-edited file must not stop ccmanager from
115
+ // starting; the worst outcome of ignoring it is no restore offer.
116
+ logger.warn(`Failed to read session records from ${this.filePath}: ${String(error)}`);
117
+ return [];
118
+ }
119
+ }
120
+ write(sessions) {
121
+ const contents = {
122
+ version: SESSION_RECORD_VERSION,
123
+ sessions,
124
+ };
125
+ try {
126
+ // Write to a sibling file and rename over the target: a crash midway
127
+ // through then leaves the previous complete file rather than a
128
+ // half-written one.
129
+ const tempPath = `${this.filePath}.${process.pid}.tmp`;
130
+ writeFileSync(tempPath, JSON.stringify(contents, null, 2));
131
+ renameSync(tempPath, this.filePath);
132
+ }
133
+ catch (error) {
134
+ logger.warn(`Failed to write session records to ${this.filePath}: ${String(error)}`);
135
+ }
136
+ }
137
+ }
138
+ export const sessionRestoreStore = new SessionRestoreStore();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import path from 'path';
5
+ import { SessionRestoreStore, SESSION_RECORD_VERSION, } from './sessionRestoreStore.js';
6
+ describe('SessionRestoreStore', () => {
7
+ let directory;
8
+ let filePath;
9
+ let store;
10
+ const record = (overrides = {}) => ({
11
+ id: 'session-1',
12
+ projectPath: '/repo',
13
+ worktreePath: '/repo/worktrees/feature',
14
+ presetId: 'preset-1',
15
+ ownerPid: 1234,
16
+ createdAt: 1,
17
+ ...overrides,
18
+ });
19
+ beforeEach(() => {
20
+ directory = mkdtempSync(path.join(tmpdir(), 'ccmanager-records-'));
21
+ filePath = path.join(directory, 'sessions.json');
22
+ store = new SessionRestoreStore(filePath);
23
+ });
24
+ afterEach(() => {
25
+ rmSync(directory, { recursive: true, force: true });
26
+ });
27
+ it('returns no sessions when nothing has been recorded yet', () => {
28
+ expect(store.list()).toEqual([]);
29
+ });
30
+ it('writes each recorded session to disk immediately', () => {
31
+ store.record(record());
32
+ const contents = JSON.parse(readFileSync(filePath, 'utf-8'));
33
+ expect(contents.version).toBe(SESSION_RECORD_VERSION);
34
+ expect(contents.sessions).toHaveLength(1);
35
+ expect(contents.sessions[0].worktreePath).toBe('/repo/worktrees/feature');
36
+ });
37
+ it('replaces an existing record with the same id instead of duplicating it', () => {
38
+ store.record(record());
39
+ store.record(record({ presetId: 'preset-2' }));
40
+ expect(store.list()).toHaveLength(1);
41
+ expect(store.list()[0]?.presetId).toBe('preset-2');
42
+ });
43
+ it('forgets the requested sessions and keeps the others', () => {
44
+ store.record(record({ id: 'session-1' }));
45
+ store.record(record({ id: 'session-2' }));
46
+ store.record(record({ id: 'session-3' }));
47
+ store.forget('session-1', 'session-3');
48
+ expect(store.list().map(session => session.id)).toEqual(['session-2']);
49
+ });
50
+ it('keeps a renamed session name, and clears it when the name is removed', () => {
51
+ store.record(record());
52
+ store.rename('session-1', 'review');
53
+ expect(store.list()[0]?.sessionName).toBe('review');
54
+ store.rename('session-1', undefined);
55
+ expect(store.list()[0]?.sessionName).toBeUndefined();
56
+ });
57
+ it('keeps records while tracking is suspended, so a shutdown does not erase them', () => {
58
+ store.record(record());
59
+ store.suspendTracking();
60
+ store.forget('session-1');
61
+ store.record(record({ id: 'session-2' }));
62
+ expect(store.list().map(session => session.id)).toEqual(['session-1']);
63
+ store.resumeTracking();
64
+ store.forget('session-1');
65
+ expect(store.list()).toEqual([]);
66
+ });
67
+ it('picks up records another process wrote instead of overwriting them', () => {
68
+ // Stands in for a second ccmanager writing to the shared file between
69
+ // this store's own writes.
70
+ store.record(record({ id: 'session-1' }));
71
+ const other = new SessionRestoreStore(filePath);
72
+ other.record(record({ id: 'other-session' }));
73
+ store.record(record({ id: 'session-2' }));
74
+ expect(store
75
+ .list()
76
+ .map(session => session.id)
77
+ .sort()).toEqual(['other-session', 'session-1', 'session-2']);
78
+ });
79
+ it('ignores a corrupted file rather than failing', () => {
80
+ writeFileSync(filePath, '{not json');
81
+ expect(store.list()).toEqual([]);
82
+ store.record(record());
83
+ expect(store.list()).toHaveLength(1);
84
+ });
85
+ it('ignores entries that are missing the fields needed to launch them', () => {
86
+ writeFileSync(filePath, JSON.stringify({
87
+ version: SESSION_RECORD_VERSION,
88
+ sessions: [record(), { id: 'broken' }],
89
+ }));
90
+ expect(store.list().map(session => session.id)).toEqual(['session-1']);
91
+ });
92
+ });
@@ -0,0 +1,46 @@
1
+ import { SessionRecord } from './sessionRestoreStore.js';
2
+ export interface RestoreFailure {
3
+ record: SessionRecord;
4
+ message: string;
5
+ }
6
+ export interface RestoreOutcome {
7
+ restored: number;
8
+ failures: RestoreFailure[];
9
+ }
10
+ /**
11
+ * Recorded sessions that this ccmanager run may offer to launch again.
12
+ *
13
+ * @param options.projectPath - When given, only sessions of that repository are
14
+ * returned (single-project mode). Omit it to consider every recorded project,
15
+ * which is what multi-project mode does.
16
+ */
17
+ export declare function listRestorableSessions(options?: {
18
+ projectPath?: string;
19
+ }): SessionRecord[];
20
+ /**
21
+ * Name of the command preset a record will be launched with, for display.
22
+ * Resolved from the current configuration rather than stored alongside the
23
+ * record, so a renamed preset shows its current name. Records whose preset no
24
+ * longer exists fall back to the default preset, as launching does.
25
+ */
26
+ export declare function describeRecordPreset(record: SessionRecord): string;
27
+ /**
28
+ * Launch each recorded session again, in the session manager the running
29
+ * ccmanager will look at for its project.
30
+ *
31
+ * Sessions are started one at a time: each one is numbered relative to the
32
+ * sessions already present in its worktree, which only holds if they are not
33
+ * created concurrently.
34
+ *
35
+ * Every record is dropped from the durable store as it is processed — a
36
+ * successfully restored session records itself anew under its new id, and a
37
+ * failed one must not keep being offered on every subsequent start.
38
+ */
39
+ export declare function restoreSessions(records: SessionRecord[], options: {
40
+ multiProject: boolean;
41
+ }): Promise<RestoreOutcome>;
42
+ /**
43
+ * Forget the offered sessions without launching them, so declining the offer
44
+ * does not make it come back on the next start.
45
+ */
46
+ export declare function discardRestorableSessions(records: SessionRecord[]): void;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @fileoverview Turns the durable session record written by
3
+ * {@link SessionRestoreStore} back into running sessions.
4
+ *
5
+ * Restoring a session means launching its command preset in its worktree
6
+ * again — nothing of the previous run's terminal output or conversation is
7
+ * brought back. An initial prompt, if the session was originally started with
8
+ * one, is deliberately not replayed: it was a one-off instruction, not part of
9
+ * the session's identity.
10
+ */
11
+ import { existsSync } from 'fs';
12
+ import { Effect, Either } from 'effect';
13
+ import { globalSessionOrchestrator } from './globalSessionOrchestrator.js';
14
+ import { sessionRestoreStore } from './sessionRestoreStore.js';
15
+ import { configReader } from './config/configReader.js';
16
+ import { formatErrorMessage } from '../utils/errorMessage.js';
17
+ import { logger } from '../utils/logger.js';
18
+ /**
19
+ * Whether a process with this id is currently running. Used to leave alone the
20
+ * sessions of another ccmanager that is still open.
21
+ *
22
+ * A process id can be reused after a reboot, in which case an unrelated live
23
+ * process makes a record look owned and its session is silently not offered.
24
+ * That is the cheaper mistake: the opposite error would start a second copy of
25
+ * a session that is already running in another ccmanager window.
26
+ */
27
+ function isProcessAlive(pid) {
28
+ if (!Number.isInteger(pid) || pid <= 0) {
29
+ return false;
30
+ }
31
+ try {
32
+ // Signal 0 performs the permission and existence checks without
33
+ // delivering a signal.
34
+ process.kill(pid, 0);
35
+ return true;
36
+ }
37
+ catch (error) {
38
+ // EPERM means the process exists but belongs to another user.
39
+ return (typeof error === 'object' &&
40
+ error !== null &&
41
+ 'code' in error &&
42
+ error.code === 'EPERM');
43
+ }
44
+ }
45
+ /**
46
+ * Recorded sessions that this ccmanager run may offer to launch again.
47
+ *
48
+ * @param options.projectPath - When given, only sessions of that repository are
49
+ * returned (single-project mode). Omit it to consider every recorded project,
50
+ * which is what multi-project mode does.
51
+ */
52
+ export function listRestorableSessions(options = {}) {
53
+ return sessionRestoreStore
54
+ .list()
55
+ .filter(record => {
56
+ if (options.projectPath && record.projectPath !== options.projectPath) {
57
+ return false;
58
+ }
59
+ // The worktree may have been deleted while ccmanager was not running.
60
+ if (!existsSync(record.worktreePath)) {
61
+ logger.info(`Skipping session restore for missing worktree: ${record.worktreePath}`);
62
+ return false;
63
+ }
64
+ return !isProcessAlive(record.ownerPid);
65
+ })
66
+ .sort((a, b) => a.createdAt - b.createdAt);
67
+ }
68
+ /**
69
+ * Name of the command preset a record will be launched with, for display.
70
+ * Resolved from the current configuration rather than stored alongside the
71
+ * record, so a renamed preset shows its current name. Records whose preset no
72
+ * longer exists fall back to the default preset, as launching does.
73
+ */
74
+ export function describeRecordPreset(record) {
75
+ const preset = record.presetId
76
+ ? Either.getOrElse(configReader.getPresetByIdEffect(record.presetId), () => undefined)
77
+ : undefined;
78
+ return preset?.name ?? configReader.getDefaultPreset()?.name ?? 'default';
79
+ }
80
+ /**
81
+ * Launch each recorded session again, in the session manager the running
82
+ * ccmanager will look at for its project.
83
+ *
84
+ * Sessions are started one at a time: each one is numbered relative to the
85
+ * sessions already present in its worktree, which only holds if they are not
86
+ * created concurrently.
87
+ *
88
+ * Every record is dropped from the durable store as it is processed — a
89
+ * successfully restored session records itself anew under its new id, and a
90
+ * failed one must not keep being offered on every subsequent start.
91
+ */
92
+ export async function restoreSessions(records, options) {
93
+ const failures = [];
94
+ let restored = 0;
95
+ for (const record of records) {
96
+ sessionRestoreStore.forget(record.id);
97
+ // Single-project mode keeps every session in the one global manager;
98
+ // multi-project mode keeps a manager per project.
99
+ const manager = globalSessionOrchestrator.getManagerForProject(options.multiProject ? record.projectPath : undefined);
100
+ const sessionEffect = record.devcontainerConfig
101
+ ? manager.createSessionWithDevcontainerEffect(record.worktreePath, record.devcontainerConfig, record.presetId)
102
+ : manager.createSessionWithPresetEffect(record.worktreePath, record.presetId);
103
+ const result = await Effect.runPromise(Effect.either(sessionEffect));
104
+ if (result._tag === 'Left') {
105
+ const message = formatErrorMessage(result.left);
106
+ logger.error(`Failed to restore session in ${record.worktreePath}: ${message}`);
107
+ failures.push({ record, message });
108
+ continue;
109
+ }
110
+ if (record.sessionName) {
111
+ manager.renameSession(result.right.id, record.sessionName);
112
+ }
113
+ restored++;
114
+ }
115
+ return { restored, failures };
116
+ }
117
+ /**
118
+ * Forget the offered sessions without launching them, so declining the offer
119
+ * does not make it come back on the next start.
120
+ */
121
+ export function discardRestorableSessions(records) {
122
+ sessionRestoreStore.forget(...records.map(record => record.id));
123
+ }
@@ -0,0 +1 @@
1
+ export {};