@bakapiano/ccsm 0.1.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.
@@ -0,0 +1,155 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ccsm — Claude Code Session Manager</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ </head>
9
+ <body>
10
+ <header class="topbar">
11
+ <div class="brand">
12
+ <span class="logo">⌘</span>
13
+ <span>ccsm</span>
14
+ <small id="serverInfo"></small>
15
+ </div>
16
+ <div class="topbar-actions">
17
+ <label class="toggle">
18
+ <input type="checkbox" id="autoRefresh" checked />
19
+ <span>auto-refresh</span>
20
+ </label>
21
+ <button id="refreshBtn" class="btn">refresh</button>
22
+ <button id="finderBtn" class="btn btn-accent" title="Open a new Claude Code session pointed at the session-manager data folder">
23
+ ask claude to find a session
24
+ </button>
25
+ </div>
26
+ </header>
27
+
28
+ <main>
29
+ <section class="panel">
30
+ <header class="panel-header">
31
+ <h2>live sessions</h2>
32
+ <span id="sessionsMeta" class="muted"></span>
33
+ </header>
34
+ <div class="table-wrap">
35
+ <table id="sessionsTable" class="data-table">
36
+ <thead>
37
+ <tr>
38
+ <th></th>
39
+ <th>title</th>
40
+ <th>cwd</th>
41
+ <th>updated</th>
42
+ <th>started</th>
43
+ <th>pid</th>
44
+ <th></th>
45
+ </tr>
46
+ </thead>
47
+ <tbody></tbody>
48
+ </table>
49
+ </div>
50
+ </section>
51
+
52
+ <section class="panel">
53
+ <header class="panel-header">
54
+ <h2>snapshot</h2>
55
+ <span id="snapshotMeta" class="muted"></span>
56
+ </header>
57
+ <div class="panel-body row gap">
58
+ <button id="snapshotSaveBtn" class="btn">save snapshot now</button>
59
+ <button id="snapshotRestoreBtn" class="btn btn-primary">restore latest snapshot</button>
60
+ <select id="historySelect" class="select" title="restore a historical snapshot">
61
+ <option value="">history…</option>
62
+ </select>
63
+ <button id="historyRestoreBtn" class="btn">restore selected</button>
64
+ </div>
65
+ <details class="panel-body">
66
+ <summary>show snapshot contents</summary>
67
+ <pre id="snapshotPreview" class="preview"></pre>
68
+ </details>
69
+ </section>
70
+
71
+ <section class="panel">
72
+ <header class="panel-header">
73
+ <h2>new session</h2>
74
+ <span class="muted">picks an unused workspace under workDir, clones missing repos, opens a fresh wt window with <code>claude</code></span>
75
+ </header>
76
+ <div class="panel-body">
77
+ <div class="row gap" id="repoPicker"></div>
78
+ <div class="row gap" style="margin-top: 8px;">
79
+ <label for="workspaceSelect">workspace:</label>
80
+ <select id="workspaceSelect" class="select">
81
+ <option value="">(auto — find or create unused)</option>
82
+ </select>
83
+ <button id="newSessionBtn" class="btn btn-primary">launch new session</button>
84
+ </div>
85
+ <div id="newSessionProgress" class="progress-list"></div>
86
+ <div id="newSessionResult" class="muted small"></div>
87
+ </div>
88
+ <details class="panel-body">
89
+ <summary>workspaces under workDir</summary>
90
+ <div id="workspaceList" class="workspace-list"></div>
91
+ </details>
92
+ </section>
93
+
94
+ <section class="panel">
95
+ <header class="panel-header"><h2>config</h2></header>
96
+ <div class="panel-body grid-2">
97
+ <label>port (restart server to apply)
98
+ <input id="cfgPort" type="number" />
99
+ </label>
100
+ <label>work dir
101
+ <input id="cfgWorkDir" type="text" />
102
+ </label>
103
+ <label>snapshot interval (ms)
104
+ <input id="cfgInterval" type="number" min="5000" />
105
+ </label>
106
+ <label>history kept
107
+ <input id="cfgKeep" type="number" min="1" />
108
+ </label>
109
+ <label>claude command
110
+ <input id="cfgClaudeCommand" type="text" placeholder="claude" />
111
+ </label>
112
+ <label>terminal
113
+ <select id="cfgTerminal" class="select"></select>
114
+ </label>
115
+ <label>command shell (for wt; lets PowerShell aliases / functions resolve)
116
+ <select id="cfgCommandShell" class="select">
117
+ <option value="pwsh">pwsh (PowerShell 7)</option>
118
+ <option value="powershell">powershell (Windows PowerShell 5.1)</option>
119
+ <option value="none">none — run command directly via wt</option>
120
+ </select>
121
+ </label>
122
+ <label class="full" style="flex-direction: row; align-items: center; gap: 8px;">
123
+ <input id="cfgAutoFocus" type="checkbox" />
124
+ <span style="color: var(--text);">auto-focus newly launched window</span>
125
+ </label>
126
+ <label class="full">finder prompt
127
+ <textarea id="cfgFinderPrompt" rows="3"></textarea>
128
+ </label>
129
+ <div class="full">
130
+ <div class="row" style="justify-content: space-between; align-items: center;">
131
+ <strong>repos</strong>
132
+ <button id="addRepoBtn" class="btn small">+ add repo</button>
133
+ </div>
134
+ <table id="reposTable" class="data-table">
135
+ <thead>
136
+ <tr>
137
+ <th>name</th><th>url</th><th>default selected</th><th></th>
138
+ </tr>
139
+ </thead>
140
+ <tbody></tbody>
141
+ </table>
142
+ </div>
143
+ </div>
144
+ <div class="panel-body">
145
+ <button id="saveConfigBtn" class="btn btn-primary">save config</button>
146
+ <span id="configSavedAt" class="muted small"></span>
147
+ </div>
148
+ </section>
149
+ </main>
150
+
151
+ <div id="toast" class="toast"></div>
152
+
153
+ <script src="/app.js" defer></script>
154
+ </body>
155
+ </html>
@@ -0,0 +1,136 @@
1
+ :root {
2
+ --bg: #0e1116;
3
+ --bg-elev: #161b22;
4
+ --bg-elev2: #1c2230;
5
+ --border: #2a3240;
6
+ --text: #e6edf3;
7
+ --text-dim: #8b95a4;
8
+ --accent: #c97f5d; /* warm Claude orange */
9
+ --primary: #4f8df9;
10
+ --success: #3fb950;
11
+ --warn: #d29922;
12
+ --danger: #f85149;
13
+ --row-hover: #1f2530;
14
+ --mono: ui-monospace, "Cascadia Mono", "JetBrains Mono", "Fira Code", Consolas, monospace;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
19
+ code, pre, .mono { font-family: var(--mono); font-size: 12.5px; }
20
+ a { color: var(--primary); }
21
+
22
+ .topbar {
23
+ position: sticky; top: 0; z-index: 10;
24
+ display: flex; align-items: center; justify-content: space-between;
25
+ padding: 10px 18px;
26
+ background: var(--bg-elev);
27
+ border-bottom: 1px solid var(--border);
28
+ }
29
+ .brand { display: flex; align-items: baseline; gap: 10px; font-weight: 600; font-size: 16px; }
30
+ .brand .logo { color: var(--accent); font-size: 18px; }
31
+ .brand small { color: var(--text-dim); font-weight: 400; font-size: 11px; }
32
+ .topbar-actions { display: flex; gap: 8px; align-items: center; }
33
+ .toggle { display: flex; align-items: center; gap: 5px; color: var(--text-dim); font-size: 12px; cursor: pointer; user-select: none; }
34
+
35
+ main { max-width: 1400px; margin: 16px auto; padding: 0 18px; display: grid; gap: 14px; }
36
+
37
+ .panel { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
38
+ .panel-header { display: flex; align-items: baseline; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--bg-elev2); }
39
+ .panel-header h2 { margin: 0; font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-dim); }
40
+ .panel-body { padding: 12px 14px; border-bottom: 1px solid var(--border); }
41
+ .panel-body:last-child { border-bottom: 0; }
42
+
43
+ .muted { color: var(--text-dim); }
44
+ .small { font-size: 12px; }
45
+
46
+ .row { display: flex; align-items: center; }
47
+ .row.gap { gap: 8px; flex-wrap: wrap; }
48
+
49
+ .btn {
50
+ background: var(--bg-elev2); color: var(--text);
51
+ border: 1px solid var(--border); border-radius: 6px;
52
+ padding: 6px 12px; cursor: pointer; font-size: 13px;
53
+ transition: background .12s, border-color .12s;
54
+ }
55
+ .btn:hover { background: #232b3a; border-color: #3a455a; }
56
+ .btn:active { transform: translateY(1px); }
57
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
58
+ .btn.small { padding: 3px 8px; font-size: 12px; }
59
+ .btn-primary { background: var(--primary); border-color: var(--primary); color: white; }
60
+ .btn-primary:hover { background: #6ba1ff; border-color: #6ba1ff; }
61
+ .btn-accent { background: var(--accent); border-color: var(--accent); color: white; }
62
+ .btn-accent:hover { background: #d99372; border-color: #d99372; }
63
+ .btn-danger { background: transparent; border-color: var(--danger); color: var(--danger); }
64
+ .btn-danger:hover { background: rgba(248,81,73,.1); }
65
+
66
+ .select, input[type=text], input[type=number], textarea {
67
+ background: var(--bg); color: var(--text);
68
+ border: 1px solid var(--border); border-radius: 6px;
69
+ padding: 5px 8px; font-size: 13px; font-family: inherit;
70
+ }
71
+ input[type=number] { width: 100px; }
72
+ textarea { width: 100%; font-family: var(--mono); }
73
+
74
+ .data-table { width: 100%; border-collapse: collapse; }
75
+ .data-table th, .data-table td { padding: 7px 12px; text-align: left; font-size: 13px; }
76
+ .data-table thead th { font-size: 11px; font-weight: 600; color: var(--text-dim); letter-spacing: .04em; text-transform: uppercase; border-bottom: 1px solid var(--border); background: var(--bg-elev2); position: sticky; top: 0; }
77
+ .data-table tbody tr { border-bottom: 1px solid var(--border); }
78
+ .data-table tbody tr:hover { background: var(--row-hover); }
79
+ .data-table tbody tr:last-child { border-bottom: 0; }
80
+ .data-table td { vertical-align: top; }
81
+ .data-table .ellipsis { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
82
+ .data-table .mono { color: var(--text-dim); }
83
+ .table-wrap { overflow-x: auto; }
84
+
85
+ .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: middle; }
86
+ .status-dot.busy { background: var(--warn); box-shadow: 0 0 8px rgba(210,153,34,.65); animation: pulse 1.2s ease-in-out infinite; }
87
+ .status-dot.idle { background: var(--success); }
88
+ .status-dot.unknown { background: var(--text-dim); }
89
+
90
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
91
+
92
+ .preview { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px; max-height: 280px; overflow: auto; white-space: pre; }
93
+
94
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; align-items: start; }
95
+ .grid-2 label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--text-dim); }
96
+ .grid-2 label.full { grid-column: 1 / -1; }
97
+ .grid-2 input, .grid-2 textarea { color: var(--text); font-size: 13px; }
98
+
99
+ .workspace-list { display: flex; flex-direction: column; gap: 6px; }
100
+ .workspace-card { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-elev2); display: flex; justify-content: space-between; align-items: center; gap: 12px; }
101
+ .workspace-card.in-use { border-color: rgba(210,153,34,.45); }
102
+ .workspace-card .name { font-weight: 600; }
103
+ .workspace-card .repos { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
104
+ .tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; background: var(--bg); border: 1px solid var(--border); color: var(--text-dim); }
105
+ .tag.warn { color: var(--warn); border-color: rgba(210,153,34,.4); }
106
+ .tag.ok { color: var(--success); border-color: rgba(63,185,80,.4); }
107
+
108
+ .repo-chip {
109
+ display: flex; align-items: center; gap: 6px;
110
+ padding: 4px 10px;
111
+ border: 1px solid var(--border); border-radius: 999px;
112
+ background: var(--bg-elev2);
113
+ cursor: pointer; user-select: none;
114
+ }
115
+ .repo-chip input { margin: 0; }
116
+ .repo-chip.checked { border-color: var(--primary); background: rgba(79,141,249,.12); }
117
+
118
+ .toast { position: fixed; bottom: 18px; right: 18px; z-index: 100; padding: 10px 14px; background: var(--bg-elev2); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 4px 18px rgba(0,0,0,.4); opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s; pointer-events: none; max-width: 420px; }
119
+ .toast.show { opacity: 1; transform: translateY(0); }
120
+ .toast.error { border-color: var(--danger); }
121
+ .toast.ok { border-color: var(--success); }
122
+
123
+ .progress-list { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
124
+ .progress-item { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; }
125
+ .progress-item .head { display: flex; justify-content: space-between; align-items: center; font-size: 12px; }
126
+ .progress-item .head .name { font-weight: 600; color: var(--text); }
127
+ .progress-item .head .phase { color: var(--text-dim); font-family: var(--mono); }
128
+ .progress-item .head .pct { color: var(--text-dim); font-family: var(--mono); }
129
+ .progress-bar { margin-top: 6px; height: 6px; border-radius: 3px; background: var(--bg-elev2); overflow: hidden; position: relative; }
130
+ .progress-bar > .fill { height: 100%; background: var(--primary); transition: width .15s; width: 0; }
131
+ .progress-bar > .fill.indeterminate { width: 30% !important; animation: indeterm 1.4s ease-in-out infinite; background: linear-gradient(90deg, transparent, var(--primary), transparent); }
132
+ .progress-item.ok .fill { background: var(--success); }
133
+ .progress-item.error .fill { background: var(--danger); }
134
+ .progress-item .detail { margin-top: 4px; font-size: 11px; color: var(--text-dim); font-family: var(--mono); min-height: 14px; }
135
+
136
+ @keyframes indeterm { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
package/server.js ADDED
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+ const express = require('express');
6
+
7
+ const { listSessions } = require('./lib/sessions');
8
+ const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
9
+ const {
10
+ saveSnapshot,
11
+ loadLatestSnapshot,
12
+ listSnapshotHistory,
13
+ loadSnapshotByFile,
14
+ restoreSnapshot,
15
+ } = require('./lib/snapshot');
16
+ const {
17
+ listWorkspaces,
18
+ findOrCreateWorkspace,
19
+ ensureReposInWorkspace,
20
+ } = require('./lib/workspace');
21
+ const {
22
+ launchNewClaude,
23
+ launchResume,
24
+ listTerminalKinds,
25
+ processNameFor,
26
+ } = require('./lib/launcher');
27
+ const {
28
+ focusByPid,
29
+ snapshotWindowsOf,
30
+ focusNewlyOpenedHwnd,
31
+ } = require('./lib/focus');
32
+
33
+ async function autoFocusAfterLaunch({ terminal, beforeHwnds, autoFocus }) {
34
+ if (!autoFocus) return;
35
+ try {
36
+ const processName = processNameFor(terminal);
37
+ if (!processName) return;
38
+ await focusNewlyOpenedHwnd(beforeHwnds, processName);
39
+ } catch (e) {
40
+ console.error('[auto-focus]', e.message);
41
+ }
42
+ }
43
+
44
+ const app = express();
45
+ app.use(express.json({ limit: '1mb' }));
46
+ app.use(express.static(path.join(__dirname, 'public')));
47
+
48
+ function asyncH(fn) {
49
+ return (req, res) => {
50
+ Promise.resolve(fn(req, res)).catch((err) => {
51
+ console.error('[api error]', err);
52
+ res.status(500).json({ error: String(err && err.message || err) });
53
+ });
54
+ };
55
+ }
56
+
57
+ // ---- sessions ----
58
+
59
+ app.get('/api/sessions', asyncH(async (_req, res) => {
60
+ const sessions = await listSessions();
61
+ res.json({ sessions, takenAt: Date.now() });
62
+ }));
63
+
64
+ // ---- config ----
65
+
66
+ app.get('/api/config', asyncH(async (_req, res) => {
67
+ res.json(await loadConfig());
68
+ }));
69
+
70
+ app.put('/api/config', asyncH(async (req, res) => {
71
+ const cfg = await saveConfig(req.body || {});
72
+ res.json(cfg);
73
+ }));
74
+
75
+ // ---- snapshot ----
76
+
77
+ app.get('/api/snapshot', asyncH(async (_req, res) => {
78
+ const snap = await loadLatestSnapshot();
79
+ res.json({ snapshot: snap });
80
+ }));
81
+
82
+ app.post('/api/snapshot', asyncH(async (_req, res) => {
83
+ const cfg = await loadConfig();
84
+ const snap = await saveSnapshot({ keep: cfg.snapshotHistoryKeep });
85
+ res.json({ snapshot: snap });
86
+ }));
87
+
88
+ app.get('/api/snapshot/history', asyncH(async (_req, res) => {
89
+ res.json({ history: await listSnapshotHistory() });
90
+ }));
91
+
92
+ app.post('/api/snapshot/restore', asyncH(async (req, res) => {
93
+ let snap;
94
+ if (req.body && req.body.file) {
95
+ snap = await loadSnapshotByFile(req.body.file);
96
+ } else {
97
+ snap = await loadLatestSnapshot();
98
+ }
99
+ if (!snap) return res.status(404).json({ error: 'no snapshot to restore' });
100
+ const cfg = await loadConfig();
101
+ const beforeHwnds = await snapshotWindowsOf(
102
+ processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
103
+ );
104
+ const result = restoreSnapshot(snap, {
105
+ terminal: cfg.terminal,
106
+ claudeCommand: cfg.claudeCommand,
107
+ commandShell: cfg.commandShell || "pwsh",
108
+ });
109
+ // For N restored windows we just focus the last one to surface restore-happened
110
+ // without strobing focus through all N.
111
+ autoFocusAfterLaunch({
112
+ terminal: cfg.terminal,
113
+ beforeHwnds,
114
+ autoFocus: cfg.autoFocusOnLaunch !== false,
115
+ });
116
+ res.json({ restored: result, takenAt: snap.takenAt, count: snap.sessions.length });
117
+ }));
118
+
119
+ // ---- workspaces ----
120
+
121
+ app.get('/api/workspaces', asyncH(async (_req, res) => {
122
+ const cfg = await loadConfig();
123
+ const workspaces = await listWorkspaces({
124
+ workDir: cfg.workDir,
125
+ repos: cfg.repos,
126
+ });
127
+ res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
128
+ }));
129
+
130
+ // ---- new session ----
131
+ // body: { repos: ["repo-a","repo-b"], workspace?: "ws-2" (override), launch?: true }
132
+ // Streams NDJSON: one JSON object per line. Event types:
133
+ // {type:"workspace", workspace, created}
134
+ // {type:"clone-start", repo}
135
+ // {type:"clone-progress", repo, phase, percent, current, total, detail}
136
+ // {type:"clone-line", repo, line} (raw git line, when no progress)
137
+ // {type:"clone-done", repo, action, path}
138
+ // {type:"clone-error", repo, error}
139
+ // {type:"launched", launched}
140
+ // {type:"done", success, error?}
141
+ app.post('/api/sessions/new', async (req, res) => {
142
+ res.setHeader('Content-Type', 'application/x-ndjson');
143
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
144
+ res.setHeader('X-Accel-Buffering', 'no');
145
+ // Disable response compression buffering — flush right away.
146
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
147
+
148
+ const emit = (obj) => {
149
+ res.write(JSON.stringify(obj) + '\n');
150
+ };
151
+ const fail = (msg, extra) => {
152
+ emit({ type: 'done', success: false, error: msg, ...extra });
153
+ res.end();
154
+ };
155
+
156
+ try {
157
+ const cfg = await loadConfig();
158
+ const wantedNames = Array.isArray(req.body && req.body.repos)
159
+ ? req.body.repos
160
+ : cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
161
+
162
+ const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
163
+ if (wantedRepos.length === 0) {
164
+ return fail('No repos selected and no defaults available');
165
+ }
166
+
167
+ let workspace;
168
+ let created = false;
169
+ if (req.body && req.body.workspace) {
170
+ const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
171
+ workspace = all.find((w) => w.name === req.body.workspace);
172
+ if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
173
+ if (workspace.inUse) {
174
+ return fail(
175
+ `workspace ${workspace.name} is in use by ${workspace.sessionsHere.length} session(s)`
176
+ );
177
+ }
178
+ } else {
179
+ const r = await findOrCreateWorkspace({
180
+ workDir: cfg.workDir,
181
+ repos: cfg.repos,
182
+ requireUnused: true,
183
+ });
184
+ workspace = r.workspace;
185
+ created = r.created;
186
+ }
187
+ emit({ type: 'workspace', workspace, created });
188
+
189
+ const cloneResults = await ensureReposInWorkspace({
190
+ workspacePath: workspace.path,
191
+ repos: wantedRepos,
192
+ onRepoStart: (repo) =>
193
+ emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
194
+ onProgress: (repo, p) =>
195
+ emit({
196
+ type: 'clone-progress',
197
+ repo: repo.name,
198
+ phase: p.phase,
199
+ percent: p.percent,
200
+ current: p.current,
201
+ total: p.total,
202
+ detail: p.detail,
203
+ }),
204
+ onLine: (repo, line) =>
205
+ emit({ type: 'clone-line', repo: repo.name, line }),
206
+ onRepoEnd: (repo, result) =>
207
+ emit({ type: 'clone-end', repo: repo.name, ...result }),
208
+ });
209
+
210
+ const failed = cloneResults.filter((r) => !r.ok);
211
+ if (failed.length > 0) {
212
+ return fail('Some repos failed to clone', { cloneResults });
213
+ }
214
+
215
+ const shouldLaunch = req.body && req.body.launch !== false;
216
+ let launched = null;
217
+ if (shouldLaunch) {
218
+ const beforeHwnds = await snapshotWindowsOf(
219
+ processNameFor(cfg.terminal) || 'WindowsTerminal.exe'
220
+ );
221
+ launched = launchNewClaude({
222
+ cwd: workspace.path,
223
+ title: workspace.name,
224
+ terminal: cfg.terminal,
225
+ claudeCommand: cfg.claudeCommand,
226
+ commandShell: cfg.commandShell || "pwsh",
227
+ });
228
+ emit({ type: 'launched', launched });
229
+ autoFocusAfterLaunch({
230
+ terminal: cfg.terminal,
231
+ beforeHwnds,
232
+ autoFocus: cfg.autoFocusOnLaunch !== false,
233
+ });
234
+ }
235
+
236
+ emit({
237
+ type: 'done',
238
+ success: true,
239
+ workspace,
240
+ created,
241
+ cloneResults,
242
+ launched,
243
+ });
244
+ res.end();
245
+ } catch (e) {
246
+ console.error('[/api/sessions/new]', e);
247
+ fail(String(e && e.message || e));
248
+ }
249
+ });
250
+
251
+ // ---- launch finder session (a claude session in the ccsm data dir pre-pointed at session data) ----
252
+ app.post('/api/sessions/finder', asyncH(async (_req, res) => {
253
+ const cfg = await loadConfig();
254
+ const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
255
+ const launched = launchNewClaude({
256
+ cwd: DATA_DIR,
257
+ title: 'ccsm finder',
258
+ extraArgs: cfg.finderPrompt ? [cfg.finderPrompt] : [],
259
+ terminal: cfg.terminal,
260
+ claudeCommand: cfg.claudeCommand,
261
+ commandShell: cfg.commandShell || 'pwsh',
262
+ });
263
+ autoFocusAfterLaunch({
264
+ terminal: cfg.terminal,
265
+ beforeHwnds,
266
+ autoFocus: cfg.autoFocusOnLaunch !== false,
267
+ });
268
+ res.json({ launched, cwd: DATA_DIR, prompt: cfg.finderPrompt });
269
+ }));
270
+
271
+ // ---- resume single session ----
272
+ app.post('/api/sessions/:sessionId/resume', asyncH(async (req, res) => {
273
+ const sessionId = req.params.sessionId;
274
+ const cwd = req.body && req.body.cwd;
275
+ if (!cwd) return res.status(400).json({ error: 'cwd required in body' });
276
+ const cfg = await loadConfig();
277
+ const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
278
+ const launched = launchResume({
279
+ cwd,
280
+ sessionId,
281
+ terminal: cfg.terminal,
282
+ claudeCommand: cfg.claudeCommand,
283
+ commandShell: cfg.commandShell || "pwsh",
284
+ });
285
+ autoFocusAfterLaunch({
286
+ terminal: cfg.terminal,
287
+ beforeHwnds,
288
+ autoFocus: cfg.autoFocusOnLaunch !== false,
289
+ });
290
+ res.json({ launched });
291
+ }));
292
+
293
+ // ---- focus the wt window that's already hosting this session ----
294
+ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
295
+ const sessionId = req.params.sessionId;
296
+ const sessions = await listSessions();
297
+ const s = sessions.find((x) => x.sessionId === sessionId);
298
+ if (!s) return res.status(404).json({ error: `session ${sessionId} not live` });
299
+ const result = await focusByPid(s.pid);
300
+ res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd }, ...result });
301
+ }));
302
+
303
+ // ---- terminal kinds ----
304
+ app.get('/api/terminals', (_req, res) => res.json({ terminals: listTerminalKinds() }));
305
+
306
+ // ---- health ----
307
+ app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid }));
308
+
309
+ // ---- auto-snapshot scheduler ----
310
+ let snapshotTimer = null;
311
+ async function startSnapshotLoop() {
312
+ const cfg = await loadConfig();
313
+ const interval = Math.max(5_000, cfg.snapshotIntervalMs || 60_000);
314
+ const tick = async () => {
315
+ try {
316
+ const cfg = await loadConfig();
317
+ await saveSnapshot({ keep: cfg.snapshotHistoryKeep });
318
+ } catch (e) {
319
+ console.error('[snapshot]', e.message);
320
+ }
321
+ };
322
+ snapshotTimer = setInterval(tick, interval);
323
+ tick().catch(() => {});
324
+ console.log(`[snapshot] auto-saving every ${Math.round(interval / 1000)}s`);
325
+ }
326
+
327
+ (async () => {
328
+ const cfg = await loadConfig();
329
+ app.listen(cfg.port, () => {
330
+ console.log(`ccsm listening on http://localhost:${cfg.port}`);
331
+ console.log(`data dir: ${DATA_DIR}`);
332
+ console.log(`work dir: ${cfg.workDir}`);
333
+ console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
334
+ });
335
+ startSnapshotLoop();
336
+ })().catch((err) => {
337
+ console.error('startup failed:', err);
338
+ process.exit(1);
339
+ });