@bakapiano/ccsm 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +222 -195
- package/README.md +77 -79
- package/lib/cliSessionWatcher.js +249 -0
- package/lib/config.js +101 -24
- package/lib/folders.js +96 -0
- package/lib/localCliSessions.js +177 -0
- package/lib/persistedSessions.js +134 -0
- package/lib/webTerminal.js +31 -18
- package/lib/workspace.js +26 -4
- package/package.json +1 -1
- package/public/assets/claude-color.svg +1 -0
- package/public/assets/codex-color.svg +1 -0
- package/public/assets/copilot-color.svg +1 -0
- package/public/css/base.css +22 -5
- package/public/css/cards.css +37 -3
- package/public/css/feedback.css +127 -43
- package/public/css/forms.css +97 -25
- package/public/css/layout.css +74 -26
- package/public/css/modal.css +40 -26
- package/public/css/responsive.css +2 -2
- package/public/css/sidebar.css +424 -25
- package/public/css/terminals.css +138 -0
- package/public/css/tokens.css +28 -12
- package/public/css/wco.css +38 -39
- package/public/css/widgets.css +1177 -6
- package/public/index.html +35 -2
- package/public/js/api.js +194 -37
- package/public/js/components/AdoptModal.js +171 -0
- package/public/js/components/App.js +1 -11
- package/public/js/components/DirectoryPicker.js +203 -0
- package/public/js/components/EntityFormModal.js +105 -0
- package/public/js/components/Modal.js +51 -0
- package/public/js/components/OfflineBanner.js +29 -23
- package/public/js/components/PageTitleBar.js +13 -0
- package/public/js/components/Picker.js +179 -0
- package/public/js/components/Popover.js +55 -0
- package/public/js/components/Sidebar.js +219 -32
- package/public/js/components/TerminalView.js +27 -3
- package/public/js/components/useDragSort.js +67 -0
- package/public/js/dialog.js +10 -2
- package/public/js/icons.js +66 -3
- package/public/js/main.js +54 -3
- package/public/js/pages/AboutPage.js +80 -0
- package/public/js/pages/ConfigurePage.js +429 -207
- package/public/js/pages/LaunchPage.js +326 -86
- package/public/js/pages/SessionsPage.js +91 -41
- package/public/js/state.js +102 -73
- package/public/manifest.webmanifest +2 -2
- package/scripts/install.js +7 -2
- package/server.js +755 -441
- package/lib/favorites.js +0 -51
- package/lib/focus.js +0 -369
- package/lib/labels.js +0 -29
- package/lib/launcher.js +0 -219
- package/lib/sessions.js +0 -272
- package/lib/snapshot.js +0 -141
- package/public/js/actions.js +0 -107
- package/public/js/components/Fab.js +0 -11
- package/public/js/components/FavoritesTable.js +0 -81
- package/public/js/components/Footer.js +0 -12
- package/public/js/components/NewSessionModal.js +0 -153
- package/public/js/components/PageHead.js +0 -33
- package/public/js/components/Pagination.js +0 -27
- package/public/js/components/RecentTable.js +0 -68
- package/public/js/components/SessionsTable.js +0 -71
- package/public/js/components/SnapshotPanel.js +0 -77
- package/public/js/components/TitleCell.js +0 -40
- package/public/js/components/WorkspacesGrid.js +0 -41
- package/public/js/pages/TerminalsPage.js +0 -74
package/server.js
CHANGED
|
@@ -4,75 +4,42 @@
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const express = require('express');
|
|
6
6
|
|
|
7
|
-
const { listSessions, listRecentSessions, findSessionMetadata } = require('./lib/sessions');
|
|
8
|
-
const { listFavorites, addFavorite, removeFavorite, loadFavorites } = require('./lib/favorites');
|
|
9
|
-
const { loadLabels, setLabel, removeLabel } = require('./lib/labels');
|
|
10
7
|
const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
|
|
11
|
-
const {
|
|
12
|
-
saveSnapshot,
|
|
13
|
-
loadLatestSnapshot,
|
|
14
|
-
listSnapshotHistory,
|
|
15
|
-
loadSnapshotByFile,
|
|
16
|
-
restoreSnapshot,
|
|
17
|
-
} = require('./lib/snapshot');
|
|
18
8
|
const {
|
|
19
9
|
listWorkspaces,
|
|
20
10
|
findOrCreateWorkspace,
|
|
21
11
|
ensureReposInWorkspace,
|
|
12
|
+
isInside,
|
|
13
|
+
dirSize,
|
|
22
14
|
} = require('./lib/workspace');
|
|
23
|
-
const {
|
|
24
|
-
launchNewClaude,
|
|
25
|
-
launchResume,
|
|
26
|
-
listTerminalKinds,
|
|
27
|
-
processNameFor,
|
|
28
|
-
} = require('./lib/launcher');
|
|
29
|
-
const {
|
|
30
|
-
focusByPid,
|
|
31
|
-
focusBySession,
|
|
32
|
-
snapshotWindowsOf,
|
|
33
|
-
focusNewlyOpenedHwnd,
|
|
34
|
-
} = require('./lib/focus');
|
|
35
15
|
const webTerminal = require('./lib/webTerminal');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
16
|
+
const persistedSessions = require('./lib/persistedSessions');
|
|
17
|
+
const folders = require('./lib/folders');
|
|
18
|
+
const cliSessionWatcher = require('./lib/cliSessionWatcher');
|
|
19
|
+
const localCliSessions = require('./lib/localCliSessions');
|
|
20
|
+
|
|
21
|
+
// One unified exit path: kill PTY children, then exit. v1.0 dropped the
|
|
22
|
+
// snapshot-on-exit behaviour because the new persistedSessions store is
|
|
23
|
+
// the source of truth (and is always on disk, not in memory).
|
|
40
24
|
let shuttingDown = false;
|
|
41
25
|
async function gracefulShutdown(reason) {
|
|
42
26
|
if (shuttingDown) return;
|
|
43
27
|
shuttingDown = true;
|
|
44
28
|
console.log(`[ccsm] shutting down · ${reason}`);
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
// indefinitely — 2s is generous (typical save is <300ms).
|
|
29
|
+
// Mark all running sessions as exited (best-effort) so the next launch
|
|
30
|
+
// doesn't show stale "running" rows.
|
|
48
31
|
try {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} catch
|
|
56
|
-
console.error('[ccsm] final snapshot skipped:', e.message);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Kill any in-process PTY children so they don't outlive us.
|
|
32
|
+
const all = await persistedSessions.loadAll();
|
|
33
|
+
for (const s of all) {
|
|
34
|
+
if (s.status === 'running') {
|
|
35
|
+
await persistedSessions.markExited(s.id, null).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
60
39
|
try { webTerminal.killAll(); } catch {}
|
|
61
|
-
|
|
62
40
|
process.exit(0);
|
|
63
41
|
}
|
|
64
42
|
|
|
65
|
-
async function autoFocusAfterLaunch({ terminal, beforeHwnds, autoFocus }) {
|
|
66
|
-
if (!autoFocus) return;
|
|
67
|
-
try {
|
|
68
|
-
const processName = processNameFor(terminal);
|
|
69
|
-
if (!processName) return;
|
|
70
|
-
await focusNewlyOpenedHwnd(beforeHwnds, processName);
|
|
71
|
-
} catch (e) {
|
|
72
|
-
console.error('[auto-focus]', e.message);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
43
|
const app = express();
|
|
77
44
|
app.use(express.json({ limit: '1mb' }));
|
|
78
45
|
|
|
@@ -101,7 +68,7 @@ app.use((req, res, next) => {
|
|
|
101
68
|
// so a contributor can iterate without pushing to GH Pages; (b) hot-reload
|
|
102
69
|
// SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
|
|
103
70
|
// both explicitly. In production (npm-installed), backend is API-only —
|
|
104
|
-
// frontend lives at https://bakapiano.github.io/ccsm/
|
|
71
|
+
// frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
|
|
105
72
|
const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
|
|
106
73
|
|
|
107
74
|
if (IS_DEV) {
|
|
@@ -118,7 +85,6 @@ if (IS_DEV) {
|
|
|
118
85
|
res.flushHeaders();
|
|
119
86
|
res.write(': connected\n\n');
|
|
120
87
|
reloadClients.add(res);
|
|
121
|
-
// Heartbeat every 25s so intermediate proxies don't kill the stream.
|
|
122
88
|
const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
|
|
123
89
|
req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
|
|
124
90
|
});
|
|
@@ -147,165 +113,453 @@ function asyncH(fn) {
|
|
|
147
113
|
};
|
|
148
114
|
}
|
|
149
115
|
|
|
150
|
-
// ----
|
|
116
|
+
// ---- helpers ----
|
|
151
117
|
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
}
|
|
118
|
+
function pickCli(cfg, requestedId) {
|
|
119
|
+
const wanted = requestedId || cfg.defaultCliId;
|
|
120
|
+
return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
|
|
121
|
+
}
|
|
156
122
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
123
|
+
// Resolve how to spawn a CLI command. Windows quirks:
|
|
124
|
+
// v1.1 — spawn strategy is now caller-controlled via cli.shell:
|
|
125
|
+
// 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
|
|
126
|
+
// Won't find pwsh aliases / functions.
|
|
127
|
+
// 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
|
|
128
|
+
// Loads $PROFILE → pwsh aliases / functions (`ccp`, `cxp`) work.
|
|
129
|
+
// Falls back to powershell.exe (5.x) if pwsh.exe absent.
|
|
130
|
+
// 'cmd' — wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
|
|
131
|
+
// and PATH-only names without pwsh dependency.
|
|
132
|
+
function resolveCommand(commandRaw, userArgs = [], shell = 'direct') {
|
|
133
|
+
if (!commandRaw) throw new Error('cli.command is empty');
|
|
134
|
+
const cmd = commandRaw.replace(/^\.[\\\/]/, '');
|
|
135
|
+
|
|
136
|
+
if (shell === 'pwsh') {
|
|
137
|
+
// Build a single -Command string so pwsh tokenizes args itself. The
|
|
138
|
+
// `& { ... }` wrapper makes pwsh execute the line as a script block —
|
|
139
|
+
// critical for functions (which aren't visible without invocation).
|
|
140
|
+
const joined = [cmd, ...userArgs.map(quoteForPwsh)].join(' ');
|
|
141
|
+
return {
|
|
142
|
+
exe: 'pwsh.exe',
|
|
143
|
+
prefixArgs: ['-NoLogo', '-NoExit', '-Command', `& { ${joined} }`],
|
|
144
|
+
fallbackExe: 'powershell.exe',
|
|
145
|
+
consumesUserArgs: true,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
165
148
|
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
149
|
+
if (shell === 'cmd') {
|
|
150
|
+
// /d skips AutoRun, /s preserves quoting, /c runs and exits.
|
|
151
|
+
const joined = [cmd, ...userArgs.map(quoteForCmd)].join(' ');
|
|
152
|
+
return {
|
|
153
|
+
exe: process.env.ComSpec || 'cmd.exe',
|
|
154
|
+
prefixArgs: ['/d', '/s', '/c', joined],
|
|
155
|
+
consumesUserArgs: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
173
158
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const live = await listSessions();
|
|
182
|
-
const livehit = live.find((s) => s.sessionId === sessionId);
|
|
183
|
-
if (livehit) {
|
|
184
|
-
info = { cwd: livehit.cwd, title: livehit.title, ...info };
|
|
185
|
-
} else {
|
|
186
|
-
const meta = await findSessionMetadata(sessionId);
|
|
187
|
-
if (meta) info = { cwd: meta.cwd, title: meta.title, gitBranch: meta.gitBranch, ...info };
|
|
159
|
+
// shell === 'direct' — bare pty.spawn. Honour .cmd/.bat/.ps1 extensions
|
|
160
|
+
// when an absolute path was provided so they still work without an
|
|
161
|
+
// explicit shell choice.
|
|
162
|
+
if (path.isAbsolute(cmd)) {
|
|
163
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
164
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
165
|
+
return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
|
|
188
166
|
}
|
|
167
|
+
if (ext === '.ps1') {
|
|
168
|
+
return { exe: 'powershell.exe', prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd], consumesUserArgs: false };
|
|
169
|
+
}
|
|
170
|
+
return { exe: cmd, prefixArgs: [], consumesUserArgs: false };
|
|
189
171
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const removed = await removeFavorite(req.params.sessionId);
|
|
196
|
-
res.json({ removed });
|
|
197
|
-
}));
|
|
172
|
+
// Bare name with shell=direct: defer to cmd.exe so Windows resolves
|
|
173
|
+
// against PATH. Same behavior as before — preserves user expectations
|
|
174
|
+
// for `claude` / `codex` configs that don't set shell.
|
|
175
|
+
return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
|
|
176
|
+
}
|
|
198
177
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
178
|
+
function quoteForPwsh(s) {
|
|
179
|
+
if (s === '' || /[\s'"`$]/.test(s)) return `'${String(s).replace(/'/g, "''")}'`;
|
|
180
|
+
return s;
|
|
181
|
+
}
|
|
182
|
+
function quoteForCmd(s) {
|
|
183
|
+
if (s === '' || /[\s"&|<>^]/.test(s)) return `"${String(s).replace(/"/g, '""')}"`;
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
206
186
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
187
|
+
function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
|
|
188
|
+
if (!webTerminal.available) {
|
|
189
|
+
const e = new Error('node-pty unavailable · cannot spawn web terminal');
|
|
190
|
+
e.code = 'PTY_UNAVAILABLE';
|
|
191
|
+
throw e;
|
|
212
192
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
193
|
+
// For shell wrappers (pwsh/cmd) we need to bake BOTH cli.args and
|
|
194
|
+
// extraArgs into the single quoted command string — otherwise extraArgs
|
|
195
|
+
// would become args to the shell itself, not the wrapped command.
|
|
196
|
+
// Re-resolve here when extraArgs is present so the quoting is correct.
|
|
197
|
+
const resolved = resolveCommand(
|
|
198
|
+
cli.command,
|
|
199
|
+
[...(cli.args || []), ...extraArgs],
|
|
200
|
+
cli.shell || 'direct',
|
|
201
|
+
);
|
|
202
|
+
const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
|
|
203
|
+
const args = consumesUserArgs
|
|
204
|
+
? prefixArgs
|
|
205
|
+
: [...prefixArgs, ...(cli.args || []), ...extraArgs];
|
|
206
|
+
// Merge user-scope PATH from registry into the env we hand the PTY.
|
|
207
|
+
const env = { ...process.env, ...(cli.env || {}) };
|
|
208
|
+
if (mergedUserPath) env.PATH = mergedUserPath;
|
|
209
|
+
const trySpawn = (executable) => webTerminal.spawn({
|
|
210
|
+
id: sessionId,
|
|
211
|
+
command: executable,
|
|
212
|
+
args,
|
|
213
|
+
cwd,
|
|
214
|
+
env,
|
|
215
|
+
meta: { ...meta, cliId: cli.id, cliName: cli.name },
|
|
216
|
+
onData: () => { persistedSessions.touch(sessionId).catch(() => {}); },
|
|
217
|
+
onExit: ({ exitCode }) => {
|
|
218
|
+
stopWatcher(sessionId);
|
|
219
|
+
persistedSessions.markExited(sessionId, exitCode).catch(() => {});
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
const entry = trySpawn(exe);
|
|
224
|
+
maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
|
|
225
|
+
return entry;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
|
|
228
|
+
const entry = trySpawn(fallbackExe);
|
|
229
|
+
maybeWatchCliSessionId({ cli, cwd, ccsmSessionId: sessionId });
|
|
230
|
+
return entry;
|
|
231
|
+
}
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
216
235
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
236
|
+
// Start a fs-watch on the CLI's transcript directory so we can capture
|
|
237
|
+
// the upstream session UUID for later precise --resume. Only kicks off
|
|
238
|
+
// for CLI types we know how to watch (claude / codex / copilot).
|
|
239
|
+
//
|
|
240
|
+
// If the watcher times out (5 min with no transcript ever written), we
|
|
241
|
+
// assume the user closed the CLI before it persisted anything — so
|
|
242
|
+
// there's nothing to resume to and the ccsm record is dead weight. Drop
|
|
243
|
+
// the persistedSessions row and kill the PTY if it somehow lingers.
|
|
244
|
+
//
|
|
245
|
+
// IMPORTANT: if the record already has a captured cliSessionId (typical
|
|
246
|
+
// for `resume` and for `adopt`-imported records), skip the watcher
|
|
247
|
+
// entirely — there's nothing left to capture, and the timeout-cleanup
|
|
248
|
+
// would otherwise wipe a perfectly good record after 5 minutes of
|
|
249
|
+
// "no new transcript".
|
|
250
|
+
// Active upstream-session-id watchers, keyed by ccsm session id. We hold
|
|
251
|
+
// onto the cleanup fn returned by cliSessionWatcher so we can tear them
|
|
252
|
+
// down when the PTY exits or the record is deleted — a still-running
|
|
253
|
+
// watcher whose ccsm session is gone would otherwise match a *future*
|
|
254
|
+
// session that happens to spawn in the same cwd and stamp the wrong id
|
|
255
|
+
// onto a dead record (or worse, onto a re-created record reusing memory).
|
|
256
|
+
const activeWatchers = new Map(); // ccsmSessionId → cleanupFn
|
|
257
|
+
|
|
258
|
+
function stopWatcher(ccsmSessionId) {
|
|
259
|
+
const cleanup = activeWatchers.get(ccsmSessionId);
|
|
260
|
+
if (!cleanup) return;
|
|
261
|
+
activeWatchers.delete(ccsmSessionId);
|
|
262
|
+
try { cleanup(); } catch {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function maybeWatchCliSessionId({ cli, cwd, ccsmSessionId }) {
|
|
266
|
+
if (!cli || !['claude', 'codex', 'copilot'].includes(cli.type)) return;
|
|
267
|
+
// If a previous watcher was still alive on this id (e.g. fast restart),
|
|
268
|
+
// tear it down first.
|
|
269
|
+
stopWatcher(ccsmSessionId);
|
|
270
|
+
try {
|
|
271
|
+
const existing = await persistedSessions.get(ccsmSessionId);
|
|
272
|
+
if (existing?.cliSessionId) {
|
|
273
|
+
console.log(`[cliSessionId] skip watcher · ${cli.type} session already known (${existing.cliSessionId})`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
} catch {}
|
|
277
|
+
const cleanup = cliSessionWatcher.captureSessionId({
|
|
278
|
+
cliType: cli.type,
|
|
279
|
+
cwd,
|
|
280
|
+
onCapture: (cliSessionId) => {
|
|
281
|
+
activeWatchers.delete(ccsmSessionId);
|
|
282
|
+
persistedSessions.update(ccsmSessionId, { cliSessionId }).catch((e) => {
|
|
283
|
+
console.error('[cliSessionId] save failed:', e.message);
|
|
284
|
+
});
|
|
285
|
+
console.log(`[cliSessionId] captured ${cli.type} session ${cliSessionId} for ccsm ${ccsmSessionId}`);
|
|
286
|
+
},
|
|
287
|
+
onTimeout: () => {
|
|
288
|
+
activeWatchers.delete(ccsmSessionId);
|
|
289
|
+
console.warn(`[cliSessionId] timeout · removing ccsm session ${ccsmSessionId} (no ${cli.type} transcript)`);
|
|
290
|
+
try { webTerminal.kill(ccsmSessionId); } catch {}
|
|
291
|
+
persistedSessions.remove(ccsmSessionId).catch((e) => {
|
|
292
|
+
console.error('[cliSessionId] remove failed:', e.message);
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
if (cleanup) activeWatchers.set(ccsmSessionId, cleanup);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Read user PATH from registry once at boot, prepend to process PATH.
|
|
300
|
+
// On platforms other than Windows or if the read fails, fall back to
|
|
301
|
+
// process.env.PATH unchanged.
|
|
302
|
+
let mergedUserPath = null;
|
|
303
|
+
function buildMergedUserPath() {
|
|
304
|
+
if (process.platform !== 'win32') return process.env.PATH;
|
|
305
|
+
try {
|
|
306
|
+
const { spawnSync } = require('node:child_process');
|
|
307
|
+
const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
|
|
308
|
+
if (r.status !== 0 || !r.stdout) return process.env.PATH;
|
|
309
|
+
const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
|
|
310
|
+
if (!line) return process.env.PATH;
|
|
311
|
+
const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
|
|
312
|
+
if (!m) return process.env.PATH;
|
|
313
|
+
// Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
|
|
314
|
+
const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
|
|
315
|
+
const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
|
|
316
|
+
const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
|
|
317
|
+
const merged = [];
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
for (const p of [...adds, ...existing]) {
|
|
320
|
+
const k = p.toLowerCase();
|
|
321
|
+
if (seen.has(k)) continue;
|
|
322
|
+
seen.add(k);
|
|
323
|
+
merged.push(p);
|
|
324
|
+
}
|
|
325
|
+
return merged.join(';');
|
|
326
|
+
} catch {
|
|
327
|
+
return process.env.PATH;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
mergedUserPath = buildMergedUserPath();
|
|
221
331
|
|
|
222
332
|
// ---- config ----
|
|
223
333
|
|
|
334
|
+
// Per-CLI install probe. Looks up the command on PATH using `where` (win)
|
|
335
|
+
// or `which` (posix). Result is cached forever — restart ccsm after
|
|
336
|
+
// installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
|
|
337
|
+
const cliProbeCache = new Map();
|
|
338
|
+
function probeCli(command) {
|
|
339
|
+
if (!command) return null;
|
|
340
|
+
if (cliProbeCache.has(command)) return cliProbeCache.get(command);
|
|
341
|
+
const { spawnSync } = require('node:child_process');
|
|
342
|
+
let resolvedPath = null;
|
|
343
|
+
try {
|
|
344
|
+
const isWin = process.platform === 'win32';
|
|
345
|
+
const cmd = isWin ? 'where.exe' : 'which';
|
|
346
|
+
const env = { ...process.env };
|
|
347
|
+
if (mergedUserPath) env.PATH = mergedUserPath;
|
|
348
|
+
const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
|
|
349
|
+
if (r.status === 0 && r.stdout) {
|
|
350
|
+
resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
|
|
351
|
+
}
|
|
352
|
+
} catch {}
|
|
353
|
+
cliProbeCache.set(command, resolvedPath);
|
|
354
|
+
return resolvedPath;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function decorateConfigWithProbes(cfg) {
|
|
358
|
+
return {
|
|
359
|
+
...cfg,
|
|
360
|
+
clis: (cfg.clis || []).map((c) => {
|
|
361
|
+
const path = probeCli(c.command);
|
|
362
|
+
return { ...c, installed: !!path, installPath: path };
|
|
363
|
+
}),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
224
367
|
app.get('/api/config', asyncH(async (_req, res) => {
|
|
225
|
-
res.json(await loadConfig());
|
|
368
|
+
res.json(decorateConfigWithProbes(await loadConfig()));
|
|
226
369
|
}));
|
|
227
370
|
|
|
228
371
|
app.put('/api/config', asyncH(async (req, res) => {
|
|
229
372
|
const cfg = await saveConfig(req.body || {});
|
|
230
|
-
res.json(cfg);
|
|
373
|
+
res.json(decorateConfigWithProbes(cfg));
|
|
231
374
|
}));
|
|
232
375
|
|
|
233
|
-
// ----
|
|
376
|
+
// ---- CLIs ----
|
|
377
|
+
// ---- folders ----
|
|
234
378
|
|
|
235
|
-
app.get('/api/
|
|
236
|
-
const
|
|
237
|
-
|
|
379
|
+
app.get('/api/folders', asyncH(async (_req, res) => {
|
|
380
|
+
const list = await folders.loadAll();
|
|
381
|
+
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
382
|
+
res.json({ folders: list });
|
|
238
383
|
}));
|
|
239
384
|
|
|
240
|
-
app.post('/api/
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
res.json({
|
|
385
|
+
app.post('/api/folders', asyncH(async (req, res) => {
|
|
386
|
+
const name = req.body && req.body.name;
|
|
387
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
388
|
+
res.json({ folder: await folders.create({ name }) });
|
|
244
389
|
}));
|
|
245
390
|
|
|
246
|
-
app.
|
|
247
|
-
|
|
391
|
+
app.put('/api/folders/:id', asyncH(async (req, res) => {
|
|
392
|
+
const updated = await folders.update(req.params.id, req.body || {});
|
|
393
|
+
if (!updated) return res.status(404).json({ error: 'not found' });
|
|
394
|
+
res.json({ folder: updated });
|
|
248
395
|
}));
|
|
249
396
|
|
|
250
|
-
app.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
397
|
+
app.delete('/api/folders/:id', asyncH(async (req, res) => {
|
|
398
|
+
// Move all sessions in this folder to Unsorted before delete.
|
|
399
|
+
const all = await persistedSessions.loadAll();
|
|
400
|
+
for (const s of all) {
|
|
401
|
+
if (s.folderId === req.params.id) {
|
|
402
|
+
await persistedSessions.setFolder(s.id, null);
|
|
403
|
+
}
|
|
256
404
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
405
|
+
const removed = await folders.remove(req.params.id);
|
|
406
|
+
res.json({ removed });
|
|
407
|
+
}));
|
|
408
|
+
|
|
409
|
+
app.post('/api/folders/reorder', asyncH(async (req, res) => {
|
|
410
|
+
const ids = req.body && req.body.ids;
|
|
411
|
+
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
|
|
412
|
+
const next = await folders.reorder(ids);
|
|
413
|
+
res.json({ folders: next });
|
|
414
|
+
}));
|
|
415
|
+
|
|
416
|
+
// ---- sessions (persisted, ccsm-owned) ----
|
|
417
|
+
|
|
418
|
+
app.get('/api/sessions', asyncH(async (_req, res) => {
|
|
419
|
+
const list = await persistedSessions.loadAll();
|
|
420
|
+
// Cross-check status against live PTY pool so a stale "running" record
|
|
421
|
+
// doesn't survive a server restart.
|
|
422
|
+
const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
|
|
423
|
+
for (const s of list) {
|
|
424
|
+
if (s.status === 'running' && !live.has(s.id)) {
|
|
425
|
+
s.status = 'exited';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
res.json({ sessions: list, takenAt: Date.now() });
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
app.put('/api/sessions/:id', asyncH(async (req, res) => {
|
|
432
|
+
const patch = {};
|
|
433
|
+
if (typeof req.body.title === 'string') patch.title = req.body.title;
|
|
434
|
+
if ('folderId' in (req.body || {})) patch.folderId = req.body.folderId || null;
|
|
435
|
+
const updated = await persistedSessions.update(req.params.id, patch);
|
|
436
|
+
if (!updated) return res.status(404).json({ error: 'not found' });
|
|
437
|
+
res.json({ session: updated });
|
|
438
|
+
}));
|
|
439
|
+
|
|
440
|
+
app.delete('/api/sessions/:id', asyncH(async (req, res) => {
|
|
441
|
+
// Kill PTY first if it's still alive, then drop the record.
|
|
442
|
+
stopWatcher(req.params.id);
|
|
443
|
+
try { webTerminal.kill(req.params.id); } catch {}
|
|
444
|
+
const removed = await persistedSessions.remove(req.params.id);
|
|
445
|
+
res.json({ removed });
|
|
275
446
|
}));
|
|
276
447
|
|
|
277
448
|
// ---- workspaces ----
|
|
278
449
|
|
|
450
|
+
// ---- directory browser ----
|
|
451
|
+
// Lets the launch picker walk the filesystem so users can pick any
|
|
452
|
+
// existing directory as the session cwd. Returns the immediate child
|
|
453
|
+
// dirs of `path` (defaults to home), plus a few hardcoded "starts"
|
|
454
|
+
// (home, workDir, drive roots on Windows).
|
|
455
|
+
app.get('/api/browse', asyncH(async (req, res) => {
|
|
456
|
+
const fs = require('node:fs/promises');
|
|
457
|
+
const os = require('node:os');
|
|
458
|
+
const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
|
|
459
|
+
let entries = [];
|
|
460
|
+
let exists = true;
|
|
461
|
+
try {
|
|
462
|
+
const list = await fs.readdir(target, { withFileTypes: true });
|
|
463
|
+
entries = list
|
|
464
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
465
|
+
.map((d) => ({ name: d.name, path: path.join(target, d.name) }))
|
|
466
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
467
|
+
} catch (e) {
|
|
468
|
+
exists = false;
|
|
469
|
+
}
|
|
470
|
+
const parent = path.dirname(target);
|
|
471
|
+
const cfg = await loadConfig();
|
|
472
|
+
const starts = [
|
|
473
|
+
{ label: 'Home', path: os.homedir() },
|
|
474
|
+
{ label: 'Work dir', path: cfg.workDir },
|
|
475
|
+
];
|
|
476
|
+
if (process.platform === 'win32') {
|
|
477
|
+
// Best-effort drive enumeration so users on D:\ etc can hop roots.
|
|
478
|
+
for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
|
|
479
|
+
const root = `${letter}:\\`;
|
|
480
|
+
try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
|
|
481
|
+
catch {}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
res.json({
|
|
485
|
+
path: target,
|
|
486
|
+
parent: parent === target ? null : parent,
|
|
487
|
+
exists,
|
|
488
|
+
entries,
|
|
489
|
+
starts,
|
|
490
|
+
});
|
|
491
|
+
}));
|
|
492
|
+
|
|
279
493
|
app.get('/api/workspaces', asyncH(async (_req, res) => {
|
|
280
494
|
const cfg = await loadConfig();
|
|
495
|
+
// listWorkspaces calls into the old "in use = ~/.claude/sessions cwd
|
|
496
|
+
// matches workspace path" logic; we just want the directory listing
|
|
497
|
+
// now, so pass empty busy paths.
|
|
281
498
|
const workspaces = await listWorkspaces({
|
|
282
499
|
workDir: cfg.workDir,
|
|
283
500
|
repos: cfg.repos,
|
|
284
501
|
});
|
|
502
|
+
// Recompute inUse based on persistedSessions instead. A workspace is
|
|
503
|
+
// in use iff any RUNNING ccsm session lives at-or-inside it.
|
|
504
|
+
const allSess = await persistedSessions.loadAll();
|
|
505
|
+
const busy = new Set(
|
|
506
|
+
allSess.filter((s) => s.status === 'running').map((s) => path.resolve(s.cwd).toLowerCase())
|
|
507
|
+
);
|
|
508
|
+
for (const w of workspaces) {
|
|
509
|
+
w.inUse = busy.has(path.resolve(w.path).toLowerCase());
|
|
510
|
+
w.sessionsHere = allSess
|
|
511
|
+
.filter((s) => s.status === 'running' && path.resolve(s.cwd).toLowerCase() === path.resolve(w.path).toLowerCase())
|
|
512
|
+
.map((s) => s.id);
|
|
513
|
+
}
|
|
514
|
+
// Compute sizes in parallel. Cheap on the typical workspace
|
|
515
|
+
// (a few repo clones) and the page is opened infrequently.
|
|
516
|
+
await Promise.all(workspaces.map(async (w) => {
|
|
517
|
+
try { w.size = await dirSize(w.path); }
|
|
518
|
+
catch { w.size = null; }
|
|
519
|
+
}));
|
|
285
520
|
res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
|
|
286
521
|
}));
|
|
287
522
|
|
|
523
|
+
// Delete a workspace directory. Refuses if any RUNNING session lives
|
|
524
|
+
// inside it, or if the resolved path escapes workDir. The name comes
|
|
525
|
+
// from the URL — we resolve it against workDir and verify containment.
|
|
526
|
+
app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
|
|
527
|
+
const fsp = require('node:fs/promises');
|
|
528
|
+
const cfg = await loadConfig();
|
|
529
|
+
const name = String(req.params.name || '');
|
|
530
|
+
// Reject anything that tries to escape via separators / traversal.
|
|
531
|
+
if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
|
|
532
|
+
return res.status(400).json({ error: 'invalid workspace name' });
|
|
533
|
+
}
|
|
534
|
+
const target = path.resolve(cfg.workDir, name);
|
|
535
|
+
if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
|
|
536
|
+
return res.status(400).json({ error: 'workspace must live under workDir' });
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
const st = await fsp.stat(target);
|
|
540
|
+
if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
|
|
541
|
+
} catch {
|
|
542
|
+
return res.status(404).json({ error: 'workspace not found' });
|
|
543
|
+
}
|
|
544
|
+
const allSess = await persistedSessions.loadAll();
|
|
545
|
+
const inUse = allSess.some((s) =>
|
|
546
|
+
s.status === 'running' && isInside(s.cwd, target)
|
|
547
|
+
);
|
|
548
|
+
if (inUse) return res.status(409).json({ error: 'workspace is in use by a running session' });
|
|
549
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
550
|
+
res.json({ ok: true });
|
|
551
|
+
}));
|
|
552
|
+
|
|
288
553
|
// ---- new session ----
|
|
289
|
-
// body: { repos
|
|
290
|
-
// Streams NDJSON:
|
|
291
|
-
// {type:"workspace", workspace, created}
|
|
292
|
-
// {type:"clone-start", repo}
|
|
293
|
-
// {type:"clone-progress", repo, phase, percent, current, total, detail}
|
|
294
|
-
// {type:"clone-line", repo, line} (raw git line, when no progress)
|
|
295
|
-
// {type:"clone-done", repo, action, path}
|
|
296
|
-
// {type:"clone-error", repo, error}
|
|
297
|
-
// {type:"launched", launched}
|
|
298
|
-
// {type:"done", success, error?}
|
|
554
|
+
// body: { cliId?, repos?, workspace?, folderId?, launch?: true }
|
|
555
|
+
// Streams NDJSON: workspace / clone-* / launched / done.
|
|
299
556
|
app.post('/api/sessions/new', async (req, res) => {
|
|
300
557
|
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
301
558
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
302
559
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
303
|
-
// Disable response compression buffering — flush right away.
|
|
304
560
|
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
305
561
|
|
|
306
|
-
const emit = (obj) => {
|
|
307
|
-
res.write(JSON.stringify(obj) + '\n');
|
|
308
|
-
};
|
|
562
|
+
const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
|
|
309
563
|
const fail = (msg, extra) => {
|
|
310
564
|
emit({ type: 'done', success: false, error: msg, ...extra });
|
|
311
565
|
res.end();
|
|
@@ -313,30 +567,38 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
313
567
|
|
|
314
568
|
try {
|
|
315
569
|
const cfg = await loadConfig();
|
|
570
|
+
const cli = pickCli(cfg, req.body && req.body.cliId);
|
|
571
|
+
if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
|
|
572
|
+
|
|
316
573
|
const explicitRepos = Array.isArray(req.body && req.body.repos);
|
|
317
574
|
const wantedNames = explicitRepos
|
|
318
575
|
? req.body.repos
|
|
319
576
|
: cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
|
|
320
|
-
|
|
321
577
|
const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
|
|
322
|
-
// Allow launching with zero repos — caller explicitly passed [] (or no
|
|
323
|
-
// defaults exist). The workspace is still created; claude just opens
|
|
324
|
-
// in an empty directory.
|
|
325
578
|
if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
|
|
326
579
|
return fail('No matching repos found');
|
|
327
580
|
}
|
|
328
581
|
|
|
329
582
|
let workspace;
|
|
330
583
|
let created = false;
|
|
331
|
-
|
|
584
|
+
// Three cwd modes:
|
|
585
|
+
// 1. body.cwd — user picked an existing directory; skip clone.
|
|
586
|
+
// 2. body.workspace — reuse a named workspace under workDir.
|
|
587
|
+
// 3. (neither) — auto-allocate a fresh ws-N.
|
|
588
|
+
if (req.body && req.body.cwd) {
|
|
589
|
+
const fsmod = require('node:fs/promises');
|
|
590
|
+
const cwd = path.resolve(String(req.body.cwd));
|
|
591
|
+
try {
|
|
592
|
+
const st = await fsmod.stat(cwd);
|
|
593
|
+
if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
|
|
594
|
+
} catch {
|
|
595
|
+
return fail(`directory not found: ${cwd}`);
|
|
596
|
+
}
|
|
597
|
+
workspace = { name: path.basename(cwd) || cwd, path: cwd };
|
|
598
|
+
} else if (req.body && req.body.workspace) {
|
|
332
599
|
const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
|
|
333
600
|
workspace = all.find((w) => w.name === req.body.workspace);
|
|
334
601
|
if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
|
|
335
|
-
if (workspace.inUse) {
|
|
336
|
-
return fail(
|
|
337
|
-
`workspace ${workspace.name} is in use by ${workspace.sessionsHere.length} session(s)`
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
602
|
} else {
|
|
341
603
|
const r = await findOrCreateWorkspace({
|
|
342
604
|
workDir: cfg.workDir,
|
|
@@ -348,87 +610,53 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
348
610
|
}
|
|
349
611
|
emit({ type: 'workspace', workspace, created });
|
|
350
612
|
|
|
351
|
-
|
|
613
|
+
// Skip clone entirely when user picked an existing directory — we
|
|
614
|
+
// don't want to dump random repos into someone's project.
|
|
615
|
+
const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
|
|
352
616
|
workspacePath: workspace.path,
|
|
353
617
|
repos: wantedRepos,
|
|
354
618
|
onRepoStart: (repo) =>
|
|
355
619
|
emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
|
|
356
620
|
onProgress: (repo, p) =>
|
|
357
|
-
emit({
|
|
358
|
-
type: 'clone-progress',
|
|
359
|
-
repo: repo.name,
|
|
360
|
-
phase: p.phase,
|
|
361
|
-
percent: p.percent,
|
|
362
|
-
current: p.current,
|
|
363
|
-
total: p.total,
|
|
364
|
-
detail: p.detail,
|
|
365
|
-
}),
|
|
621
|
+
emit({ type: 'clone-progress', repo: repo.name, ...p }),
|
|
366
622
|
onLine: (repo, line) =>
|
|
367
623
|
emit({ type: 'clone-line', repo: repo.name, line }),
|
|
368
624
|
onRepoEnd: (repo, result) =>
|
|
369
625
|
emit({ type: 'clone-end', repo: repo.name, ...result }),
|
|
370
626
|
});
|
|
371
|
-
|
|
372
627
|
const failed = cloneResults.filter((r) => !r.ok);
|
|
373
|
-
if (failed.length > 0) {
|
|
374
|
-
return fail('Some repos failed to clone', { cloneResults });
|
|
375
|
-
}
|
|
628
|
+
if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
|
|
376
629
|
|
|
377
630
|
const shouldLaunch = req.body && req.body.launch !== false;
|
|
378
631
|
let launched = null;
|
|
379
632
|
if (shouldLaunch) {
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const cmd = cfg.claudeCommand || 'claude';
|
|
394
|
-
const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
|
|
395
|
-
const entry = webTerminal.spawn({
|
|
396
|
-
command: wrap,
|
|
397
|
-
args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${workspace.path.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}'`],
|
|
633
|
+
// Create the persistedSessions record FIRST so spawnCliSession can
|
|
634
|
+
// use its id as the PTY id (matching ids simplify resume/attach).
|
|
635
|
+
const record = await persistedSessions.create({
|
|
636
|
+
cliId: cli.id,
|
|
637
|
+
cwd: workspace.path,
|
|
638
|
+
workspace: workspace.name,
|
|
639
|
+
repos: wantedRepos.map((r) => r.name),
|
|
640
|
+
folderId: (req.body && req.body.folderId) || null,
|
|
641
|
+
title: '',
|
|
642
|
+
});
|
|
643
|
+
try {
|
|
644
|
+
const entry = spawnCliSession({
|
|
645
|
+
cli,
|
|
398
646
|
cwd: workspace.path,
|
|
647
|
+
sessionId: record.id,
|
|
399
648
|
meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
|
|
400
649
|
});
|
|
401
|
-
|
|
650
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
651
|
+
launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
|
|
402
652
|
emit({ type: 'launched', launched });
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
);
|
|
407
|
-
launched = launchNewClaude({
|
|
408
|
-
cwd: workspace.path,
|
|
409
|
-
title: workspace.name,
|
|
410
|
-
terminal: cfg.terminal,
|
|
411
|
-
claudeCommand: cfg.claudeCommand,
|
|
412
|
-
commandShell: cfg.commandShell || 'pwsh',
|
|
413
|
-
});
|
|
414
|
-
launched = { mode: 'wt', ...launched };
|
|
415
|
-
emit({ type: 'launched', launched });
|
|
416
|
-
autoFocusAfterLaunch({
|
|
417
|
-
terminal: cfg.terminal,
|
|
418
|
-
beforeHwnds,
|
|
419
|
-
autoFocus: cfg.autoFocusOnLaunch !== false,
|
|
420
|
-
});
|
|
653
|
+
} catch (e) {
|
|
654
|
+
await persistedSessions.markExited(record.id, null);
|
|
655
|
+
return fail(`spawn failed: ${e.message}`);
|
|
421
656
|
}
|
|
422
657
|
}
|
|
423
658
|
|
|
424
|
-
emit({
|
|
425
|
-
type: 'done',
|
|
426
|
-
success: true,
|
|
427
|
-
workspace,
|
|
428
|
-
created,
|
|
429
|
-
cloneResults,
|
|
430
|
-
launched,
|
|
431
|
-
});
|
|
659
|
+
emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
|
|
432
660
|
res.end();
|
|
433
661
|
} catch (e) {
|
|
434
662
|
console.error('[/api/sessions/new]', e);
|
|
@@ -436,130 +664,133 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
436
664
|
}
|
|
437
665
|
});
|
|
438
666
|
|
|
439
|
-
// ----
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
meta: { title: 'ccsm finder', cwd: DATA_DIR },
|
|
457
|
-
});
|
|
458
|
-
return res.json({ launched: { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' }, cwd: DATA_DIR, prompt: cfg.finderPrompt });
|
|
459
|
-
}
|
|
460
|
-
const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
|
|
461
|
-
const launched = launchNewClaude({
|
|
462
|
-
cwd: DATA_DIR,
|
|
463
|
-
title: 'ccsm finder',
|
|
464
|
-
extraArgs: cfg.finderPrompt ? [cfg.finderPrompt] : [],
|
|
465
|
-
terminal: cfg.terminal,
|
|
466
|
-
claudeCommand: cfg.claudeCommand,
|
|
467
|
-
commandShell: cfg.commandShell || 'pwsh',
|
|
468
|
-
});
|
|
469
|
-
autoFocusAfterLaunch({
|
|
470
|
-
terminal: cfg.terminal,
|
|
471
|
-
beforeHwnds,
|
|
472
|
-
autoFocus: cfg.autoFocusOnLaunch !== false,
|
|
473
|
-
});
|
|
474
|
-
res.json({ launched: { mode: 'wt', ...launched }, cwd: DATA_DIR, prompt: cfg.finderPrompt });
|
|
667
|
+
// ---- list local CLI sessions discovered on disk (for "adopt") ----
|
|
668
|
+
// Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
|
|
669
|
+
// aren't yet adopted by ccsm. Frontend uses this in the Import modal.
|
|
670
|
+
app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
|
|
671
|
+
const type = String(req.params.cliType || '').toLowerCase();
|
|
672
|
+
if (!['claude', 'codex', 'copilot'].includes(type)) {
|
|
673
|
+
return res.status(400).json({ error: `unsupported cli type: ${type}` });
|
|
674
|
+
}
|
|
675
|
+
const [discovered, adopted] = await Promise.all([
|
|
676
|
+
localCliSessions.listForType(type),
|
|
677
|
+
persistedSessions.loadAll(),
|
|
678
|
+
]);
|
|
679
|
+
const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
|
|
680
|
+
const items = discovered
|
|
681
|
+
.map((s) => ({ ...s, adopted: adoptedIds.has(s.cliSessionId) }))
|
|
682
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
683
|
+
res.json({ sessions: items });
|
|
475
684
|
}));
|
|
476
685
|
|
|
477
|
-
// ----
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
686
|
+
// ---- adopt: create a ccsm record pointing at an existing CLI session ----
|
|
687
|
+
// Body: { cliId, cliSessionId, cwd, title?, folderId? }
|
|
688
|
+
// Doesn't spawn — the new entry shows up as "exited" in the sidebar;
|
|
689
|
+
// clicking it kicks off the regular resume flow which uses
|
|
690
|
+
// `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
|
|
691
|
+
app.post('/api/sessions/adopt', asyncH(async (req, res) => {
|
|
692
|
+
const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
|
|
693
|
+
if (!cliId || !cliSessionId || !cwd) {
|
|
694
|
+
return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
|
|
695
|
+
}
|
|
482
696
|
const cfg = await loadConfig();
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
697
|
+
const cli = pickCli(cfg, cliId);
|
|
698
|
+
if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
|
|
699
|
+
|
|
700
|
+
// Normalize the cwd up front. /api/sessions/new also resolves cwd, and
|
|
701
|
+
// the workspaces "in use" check (GET /api/workspaces) does
|
|
702
|
+
// path.resolve(s.cwd).toLowerCase() — adopted records must match the
|
|
703
|
+
// same shape, otherwise an adopted+running session leaves its
|
|
704
|
+
// workspace falsely marked as free and a fresh launch could collide.
|
|
705
|
+
const resolvedCwd = path.resolve(cwd);
|
|
706
|
+
try {
|
|
707
|
+
const fsmod = require('node:fs/promises');
|
|
708
|
+
const st = await fsmod.stat(resolvedCwd);
|
|
709
|
+
if (!st.isDirectory()) {
|
|
710
|
+
return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
|
|
489
711
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const entry = webTerminal.spawn({
|
|
493
|
-
command: wrap,
|
|
494
|
-
args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}' --resume '${sessionId.replace(/'/g, "''")}'`],
|
|
495
|
-
cwd,
|
|
496
|
-
meta: { title: `resume ${sessionId.slice(0, 8)}`, cwd, sessionId },
|
|
497
|
-
});
|
|
498
|
-
return res.json({ launched: { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' } });
|
|
712
|
+
} catch (e) {
|
|
713
|
+
return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
|
|
499
714
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
715
|
+
|
|
716
|
+
// Refuse duplicates: if any ccsm record already owns this upstream
|
|
717
|
+
// session id, return it so the caller can jump to it.
|
|
718
|
+
const all = await persistedSessions.loadAll();
|
|
719
|
+
const dup = all.find((s) => s.cliSessionId === cliSessionId);
|
|
720
|
+
if (dup) return res.json({ session: dup, alreadyAdopted: true });
|
|
721
|
+
|
|
722
|
+
const workspace = path.basename(resolvedCwd) || resolvedCwd;
|
|
723
|
+
// Create directly with status='exited' + cliSessionId set, so a
|
|
724
|
+
// concurrent GET /api/sessions can never observe a "running but no
|
|
725
|
+
// PTY" intermediate state.
|
|
726
|
+
const record = await persistedSessions.create({
|
|
727
|
+
cliId,
|
|
728
|
+
cwd: resolvedCwd,
|
|
729
|
+
workspace,
|
|
730
|
+
folderId: folderId || null,
|
|
731
|
+
title: title || '',
|
|
732
|
+
repos: [],
|
|
733
|
+
status: 'exited',
|
|
734
|
+
cliSessionId,
|
|
512
735
|
});
|
|
513
|
-
res.json({
|
|
736
|
+
res.json({ session: record, alreadyAdopted: false });
|
|
514
737
|
}));
|
|
515
738
|
|
|
516
|
-
// ----
|
|
517
|
-
app.post('/api/sessions/:
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
739
|
+
// ---- resume a previous session in the same cwd / cli ----
|
|
740
|
+
app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
741
|
+
const record = await persistedSessions.get(req.params.id);
|
|
742
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
743
|
+
// Already running and attached → no-op, just return its id.
|
|
744
|
+
const live = webTerminal.get(record.id);
|
|
745
|
+
if (live && !live.exitedAt) {
|
|
746
|
+
return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
|
|
747
|
+
}
|
|
522
748
|
const cfg = await loadConfig();
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
749
|
+
const cli = pickCli(cfg, record.cliId);
|
|
750
|
+
if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
|
|
751
|
+
try {
|
|
752
|
+
// Prefer precise --resume <cliSessionId> when we have one captured;
|
|
753
|
+
// fall back to cli.resumeArgs (--continue / resume --last) otherwise.
|
|
754
|
+
const extraArgs = buildResumeArgs(cli, record);
|
|
755
|
+
const entry = spawnCliSession({
|
|
756
|
+
cli,
|
|
757
|
+
cwd: record.cwd,
|
|
758
|
+
sessionId: record.id,
|
|
759
|
+
meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
|
|
760
|
+
extraArgs,
|
|
761
|
+
});
|
|
762
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
763
|
+
res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
|
|
764
|
+
} catch (e) {
|
|
765
|
+
res.status(500).json({ error: e.message });
|
|
766
|
+
}
|
|
531
767
|
}));
|
|
532
768
|
|
|
533
|
-
//
|
|
534
|
-
|
|
769
|
+
// Build the args appended on resume:
|
|
770
|
+
// When ccsm has captured the upstream CLI's session UUID and the CLI
|
|
771
|
+
// defines `resumeIdArgs` (e.g. ['--resume', '<id>']), we substitute the
|
|
772
|
+
// <id> placeholder and use those for a precise resume. Otherwise we
|
|
773
|
+
// fall back to `cli.resumeArgs` (e.g. ['--continue']).
|
|
774
|
+
function buildResumeArgs(cli, record) {
|
|
775
|
+
const id = record.cliSessionId;
|
|
776
|
+
const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
|
|
777
|
+
if (id && tpl.length > 0) {
|
|
778
|
+
return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
|
|
779
|
+
}
|
|
780
|
+
return Array.isArray(cli.resumeArgs) ? cli.resumeArgs : [];
|
|
781
|
+
}
|
|
535
782
|
|
|
536
|
-
// ---- capabilities probe
|
|
537
|
-
// the "open in this page" radio option. node-pty is optional, install-failure
|
|
538
|
-
// degrades us to wt-only. ----
|
|
783
|
+
// ---- capabilities probe ----
|
|
539
784
|
app.get('/api/capabilities', (_req, res) => res.json({
|
|
540
785
|
webTerminal: webTerminal.available,
|
|
541
786
|
webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
|
|
542
787
|
}));
|
|
543
788
|
|
|
544
|
-
// ---- web terminals · list / kill ----
|
|
545
|
-
// (creation happens through /api/sessions/new with terminal:'web'; attach is
|
|
546
|
-
// over WebSocket below.)
|
|
547
|
-
app.get('/api/sessions/web', (_req, res) => res.json({ terminals: webTerminal.list() }));
|
|
548
|
-
|
|
549
|
-
app.delete('/api/sessions/web/:id', (req, res) => {
|
|
550
|
-
const ok = webTerminal.kill(req.params.id);
|
|
551
|
-
res.json({ killed: ok });
|
|
552
|
-
});
|
|
553
|
-
|
|
554
789
|
// ---- health ----
|
|
555
790
|
const pkg = require('./package.json');
|
|
556
791
|
app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
|
|
557
792
|
|
|
558
793
|
// ---- lifecycle ----
|
|
559
|
-
// State shared by /api/spawn-browser (opens another window into this server)
|
|
560
|
-
// and the heartbeat watchdog (exits the server if no client has pinged for
|
|
561
|
-
// HEARTBEAT_TIMEOUT_MS). Heartbeat is the safety net behind the primary
|
|
562
|
-
// "browser child exits → server exits" mechanism wired up after listen.
|
|
563
794
|
let currentPort = 0;
|
|
564
795
|
let frontendUrl = '';
|
|
565
796
|
let lastHeartbeat = Date.now();
|
|
@@ -573,41 +804,153 @@ app.post('/api/heartbeat', (_req, res) => {
|
|
|
573
804
|
});
|
|
574
805
|
|
|
575
806
|
app.post('/api/spawn-browser', asyncH(async (_req, res) => {
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
openInBrowser(frontendUrl || `http://localhost:${currentPort}`, mode);
|
|
579
|
-
res.json({ ok: true, mode, url: frontendUrl });
|
|
807
|
+
const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
|
|
808
|
+
res.json({ ok: true, mode: opened.kind, url: frontendUrl });
|
|
580
809
|
}));
|
|
581
810
|
|
|
582
|
-
// Graceful shutdown · the uninstall script and the auto-upgrade path in
|
|
583
|
-
// the launcher both call this. We reply first so the caller doesn't see
|
|
584
|
-
// a torn connection, then exit on the next tick.
|
|
585
811
|
app.post('/api/shutdown', (_req, res) => {
|
|
586
812
|
res.json({ ok: true, bye: 'shutting down' });
|
|
587
|
-
// setImmediate so the response flushes before we tear the server down.
|
|
588
813
|
setImmediate(() => gracefulShutdown('/api/shutdown'));
|
|
589
814
|
});
|
|
590
815
|
|
|
591
|
-
// ----
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
816
|
+
// ---- version / upgrade ----
|
|
817
|
+
// `/api/version` reports the installed version (= pkg.version) and, if
|
|
818
|
+
// reachable, the latest published on the npm registry. The result is
|
|
819
|
+
// cached for 30 minutes in memory so the AboutPage poll doesn't hit the
|
|
820
|
+
// registry on every render.
|
|
821
|
+
//
|
|
822
|
+
// `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
|
|
823
|
+
// detached child. When the install completes, the child re-spawns `ccsm`
|
|
824
|
+
// (also detached) so the launcher comes back up on the new version, and
|
|
825
|
+
// the current server gracefulShutdowns. The frontend's OfflineBanner
|
|
826
|
+
// covers the gap; the version router picks up the new version on the
|
|
827
|
+
// next probe.
|
|
828
|
+
const VERSION_CACHE_MS = 30 * 60_000;
|
|
829
|
+
let versionCache = null; // { latest, fetchedAt }
|
|
830
|
+
let upgradeInFlight = false;
|
|
831
|
+
|
|
832
|
+
async function fetchLatestFromNpm() {
|
|
833
|
+
// Node 18+ has a global fetch. Time out the registry call to avoid
|
|
834
|
+
// hanging the response when the user is offline / behind a captive
|
|
835
|
+
// portal.
|
|
836
|
+
const ctrl = new AbortController();
|
|
837
|
+
const t = setTimeout(() => ctrl.abort(), 4000);
|
|
838
|
+
try {
|
|
839
|
+
const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
|
|
840
|
+
headers: { 'Accept': 'application/json' },
|
|
841
|
+
signal: ctrl.signal,
|
|
842
|
+
});
|
|
843
|
+
if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
|
|
844
|
+
const j = await r.json();
|
|
845
|
+
return String(j.version || '');
|
|
846
|
+
} finally {
|
|
847
|
+
clearTimeout(t);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function cmpSemver(a, b) {
|
|
852
|
+
const pa = String(a || '').split('.').map(Number);
|
|
853
|
+
const pb = String(b || '').split('.').map(Number);
|
|
854
|
+
for (let i = 0; i < 3; i++) {
|
|
855
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
856
|
+
if (x > y) return 1;
|
|
857
|
+
if (x < y) return -1;
|
|
858
|
+
}
|
|
859
|
+
return 0;
|
|
607
860
|
}
|
|
608
861
|
|
|
609
|
-
|
|
610
|
-
|
|
862
|
+
app.get('/api/version', asyncH(async (req, res) => {
|
|
863
|
+
const force = String(req.query.refresh || '') === '1';
|
|
864
|
+
const now = Date.now();
|
|
865
|
+
if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
|
|
866
|
+
return res.json({
|
|
867
|
+
current: pkg.version,
|
|
868
|
+
latest: versionCache.latest,
|
|
869
|
+
updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
|
|
870
|
+
fetchedAt: versionCache.fetchedAt,
|
|
871
|
+
cached: true,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
try {
|
|
875
|
+
const latest = await fetchLatestFromNpm();
|
|
876
|
+
versionCache = { latest, fetchedAt: now };
|
|
877
|
+
res.json({
|
|
878
|
+
current: pkg.version,
|
|
879
|
+
latest,
|
|
880
|
+
updateAvailable: cmpSemver(latest, pkg.version) > 0,
|
|
881
|
+
fetchedAt: now,
|
|
882
|
+
cached: false,
|
|
883
|
+
});
|
|
884
|
+
} catch (e) {
|
|
885
|
+
// Swallow: surface "unknown" so the UI doesn't keep showing a stale
|
|
886
|
+
// "update available" badge based on a 6-hour-old cached value.
|
|
887
|
+
res.json({
|
|
888
|
+
current: pkg.version,
|
|
889
|
+
latest: null,
|
|
890
|
+
updateAvailable: false,
|
|
891
|
+
fetchedAt: now,
|
|
892
|
+
error: String(e.message || e),
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}));
|
|
896
|
+
|
|
897
|
+
app.post('/api/upgrade', asyncH(async (req, res) => {
|
|
898
|
+
if (upgradeInFlight) {
|
|
899
|
+
return res.status(409).json({ error: 'upgrade already in progress' });
|
|
900
|
+
}
|
|
901
|
+
upgradeInFlight = true;
|
|
902
|
+
const target = String((req.body && req.body.target) || 'latest');
|
|
903
|
+
// Refuse anything that doesn't look like a semver dist-tag or version
|
|
904
|
+
// — defends against `;` etc. winding up in the spawn argv even though
|
|
905
|
+
// we don't shell out.
|
|
906
|
+
if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
|
|
907
|
+
upgradeInFlight = false;
|
|
908
|
+
return res.status(400).json({ error: `invalid target: ${target}` });
|
|
909
|
+
}
|
|
910
|
+
console.log(`[upgrade] starting npm i -g @bakapiano/ccsm@${target}`);
|
|
911
|
+
res.json({ ok: true, started: true, target });
|
|
912
|
+
|
|
913
|
+
// Defer the actual spawn so the HTTP response flushes first.
|
|
914
|
+
setImmediate(() => {
|
|
915
|
+
const { spawn } = require('node:child_process');
|
|
916
|
+
const npmExe = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
917
|
+
const args = ['i', '-g', `@bakapiano/ccsm@${target}`];
|
|
918
|
+
const child = spawn(npmExe, args, {
|
|
919
|
+
detached: true,
|
|
920
|
+
stdio: 'ignore',
|
|
921
|
+
windowsHide: true,
|
|
922
|
+
shell: false,
|
|
923
|
+
});
|
|
924
|
+
child.on('error', (e) => {
|
|
925
|
+
console.error('[upgrade] npm spawn failed:', e.message);
|
|
926
|
+
upgradeInFlight = false;
|
|
927
|
+
});
|
|
928
|
+
child.on('exit', (code) => {
|
|
929
|
+
console.log(`[upgrade] npm exit ${code}`);
|
|
930
|
+
upgradeInFlight = false;
|
|
931
|
+
if (code !== 0) return;
|
|
932
|
+
// Install succeeded → spawn a fresh ccsm and shut down. The
|
|
933
|
+
// launcher already detaches on its own.
|
|
934
|
+
try {
|
|
935
|
+
const ccsmCmd = process.platform === 'win32' ? 'ccsm.cmd' : 'ccsm';
|
|
936
|
+
const respawn = spawn(ccsmCmd, [], {
|
|
937
|
+
detached: true,
|
|
938
|
+
stdio: 'ignore',
|
|
939
|
+
windowsHide: true,
|
|
940
|
+
shell: false,
|
|
941
|
+
env: { ...process.env, CCSM_NO_BROWSER: '1' },
|
|
942
|
+
});
|
|
943
|
+
respawn.unref();
|
|
944
|
+
} catch (e) {
|
|
945
|
+
console.error('[upgrade] respawn failed:', e.message);
|
|
946
|
+
}
|
|
947
|
+
setTimeout(() => gracefulShutdown('upgrade'), 1500);
|
|
948
|
+
});
|
|
949
|
+
child.unref();
|
|
950
|
+
});
|
|
951
|
+
}));
|
|
952
|
+
|
|
953
|
+
|
|
611
954
|
function listenWithFallback(preferred) {
|
|
612
955
|
return new Promise((resolve, reject) => {
|
|
613
956
|
const attempt = (port, tries) => {
|
|
@@ -616,7 +959,7 @@ function listenWithFallback(preferred) {
|
|
|
616
959
|
server.once('error', (err) => {
|
|
617
960
|
if (err.code !== 'EADDRINUSE') return reject(err);
|
|
618
961
|
if (tries < 9) attempt(port + 1, tries + 1);
|
|
619
|
-
else if (tries === 9) attempt(0, tries + 1);
|
|
962
|
+
else if (tries === 9) attempt(0, tries + 1);
|
|
620
963
|
else reject(err);
|
|
621
964
|
});
|
|
622
965
|
};
|
|
@@ -640,39 +983,33 @@ function findAppModeBrowser() {
|
|
|
640
983
|
return null;
|
|
641
984
|
}
|
|
642
985
|
|
|
643
|
-
|
|
644
|
-
|
|
986
|
+
// Auto-open the frontend in a browser when ccsm boots. Strategy: try a
|
|
987
|
+
// chromeless app window first (Edge/Chrome --app=); if neither is
|
|
988
|
+
// installed, fall back to the OS default browser as a regular tab. On
|
|
989
|
+
// non-Windows we skip — the bundled launcher isn't ported yet.
|
|
990
|
+
function openInBrowser(url) {
|
|
991
|
+
if (process.platform !== 'win32') return { kind: 'none', child: null };
|
|
645
992
|
const { spawn } = require('node:child_process');
|
|
646
993
|
const fs = require('node:fs');
|
|
647
|
-
|
|
648
|
-
if (
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
],
|
|
665
|
-
{ detached: true, stdio: 'ignore' }
|
|
666
|
-
);
|
|
667
|
-
child.unref();
|
|
668
|
-
return { kind: 'app', child };
|
|
669
|
-
}
|
|
670
|
-
console.log('[ccsm] no Edge/Chrome found for app mode, falling back to default browser');
|
|
994
|
+
const exe = findAppModeBrowser();
|
|
995
|
+
if (exe) {
|
|
996
|
+
const profileDir = path.join(DATA_DIR, 'browser-profile');
|
|
997
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
998
|
+
const child = spawn(
|
|
999
|
+
exe,
|
|
1000
|
+
[
|
|
1001
|
+
`--app=${url}`,
|
|
1002
|
+
`--user-data-dir=${profileDir}`,
|
|
1003
|
+
'--window-size=1500,1100',
|
|
1004
|
+
'--no-first-run',
|
|
1005
|
+
'--no-default-browser-check',
|
|
1006
|
+
],
|
|
1007
|
+
{ detached: true, stdio: 'ignore' }
|
|
1008
|
+
);
|
|
1009
|
+
child.unref();
|
|
1010
|
+
return { kind: 'app', child };
|
|
671
1011
|
}
|
|
672
|
-
|
|
673
|
-
// mode === 'tab' (or app-mode fallback). cmd's `start` builtin exits
|
|
674
|
-
// immediately after launching the default browser — the child handle
|
|
675
|
-
// isn't usable for lifecycle tracking.
|
|
1012
|
+
console.log('[ccsm] no Edge/Chrome found, opening default browser');
|
|
676
1013
|
const child = spawn('cmd.exe', ['/c', 'start', '', url], {
|
|
677
1014
|
detached: true,
|
|
678
1015
|
stdio: 'ignore',
|
|
@@ -684,24 +1021,29 @@ function openInBrowser(url, mode) {
|
|
|
684
1021
|
|
|
685
1022
|
(async () => {
|
|
686
1023
|
const cfg = await loadConfig();
|
|
687
|
-
// CCSM_PORT env var wins over config — handy for running a dev instance
|
|
688
|
-
// on a non-default port (e.g. 7778) while a prod ccsm keeps 7777.
|
|
689
1024
|
const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
|
|
690
1025
|
const { server, port } = await listenWithFallback(preferredPort);
|
|
691
1026
|
currentPort = port;
|
|
692
1027
|
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
1028
|
+
// On boot, mark any persisted "running" sessions as exited — they
|
|
1029
|
+
// belong to a previous server process whose PTYs are gone.
|
|
1030
|
+
try {
|
|
1031
|
+
const all = await persistedSessions.loadAll();
|
|
1032
|
+
for (const s of all) {
|
|
1033
|
+
if (s.status === 'running') {
|
|
1034
|
+
await persistedSessions.markExited(s.id, null);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
console.error('[ccsm] could not reconcile persisted sessions:', e.message);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
696
1041
|
if (webTerminal.available) {
|
|
697
1042
|
let WebSocketServer;
|
|
698
1043
|
try { ({ WebSocketServer } = require('ws')); } catch {}
|
|
699
1044
|
if (WebSocketServer) {
|
|
700
1045
|
const wss = new WebSocketServer({ noServer: true });
|
|
701
1046
|
server.on('upgrade', (req, socket, head) => {
|
|
702
|
-
// Origin check · same allow-list as REST CORS. Browsers always
|
|
703
|
-
// send Origin on WebSocket upgrades; missing Origin = non-browser
|
|
704
|
-
// client which we tolerate (curl etc).
|
|
705
1047
|
const origin = req.headers.origin;
|
|
706
1048
|
if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
|
|
707
1049
|
socket.destroy();
|
|
@@ -716,57 +1058,38 @@ function openInBrowser(url, mode) {
|
|
|
716
1058
|
}
|
|
717
1059
|
}
|
|
718
1060
|
|
|
719
|
-
// OS signals · run a graceful shutdown (which saves a final snapshot
|
|
720
|
-
// and kills PTY children) before exiting.
|
|
721
1061
|
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
722
1062
|
process.on(sig, () => gracefulShutdown(sig));
|
|
723
1063
|
}
|
|
724
|
-
// Last-resort cleanup on sync exit (process.on('exit') can't await
|
|
725
|
-
// anything, so it's only a safety net for PTY children).
|
|
726
1064
|
process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
|
|
1065
|
+
|
|
727
1066
|
const apiUrl = `http://localhost:${port}`;
|
|
728
|
-
// What URL we open in the auto-spawned browser:
|
|
729
|
-
// dev → localhost (backend still serves public/ here)
|
|
730
|
-
// prod → hosted frontend on GH Pages (backend is API-only)
|
|
731
1067
|
const FRONTEND_URL = IS_DEV
|
|
732
1068
|
? apiUrl
|
|
733
|
-
: 'https://bakapiano.github.io/ccsm/
|
|
1069
|
+
: 'https://bakapiano.github.io/ccsm/';
|
|
734
1070
|
frontendUrl = FRONTEND_URL;
|
|
735
1071
|
console.log(`ccsm listening on ${apiUrl}${port !== cfg.port ? ` (requested ${cfg.port}, was taken)` : ''}`);
|
|
736
1072
|
console.log(`frontend at ${FRONTEND_URL}`);
|
|
737
1073
|
console.log(`data dir: ${DATA_DIR}`);
|
|
738
1074
|
console.log(`work dir: ${cfg.workDir}`);
|
|
739
|
-
console.log(`
|
|
740
|
-
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
// browser window. msedge.exe runs with its own --user-data-dir process
|
|
750
|
-
// group, so when the user closes the window it actually exits — and
|
|
751
|
-
// the spawned child handle we hold here fires 'exit'. Skip if the user
|
|
752
|
-
// explicitly asked the server to stay alive (e.g. an automation host).
|
|
1075
|
+
console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
|
|
1076
|
+
|
|
1077
|
+
// CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
|
|
1078
|
+
// the auto-open entirely. Otherwise try app-mode (chromeless Edge/Chrome
|
|
1079
|
+
// window); if no such browser is installed, openInBrowser falls back to
|
|
1080
|
+
// the OS default browser on its own.
|
|
1081
|
+
const opened = process.env.CCSM_NO_BROWSER === '1'
|
|
1082
|
+
? { kind: 'none', child: null }
|
|
1083
|
+
: openInBrowser(FRONTEND_URL);
|
|
1084
|
+
|
|
753
1085
|
if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
754
1086
|
const launchedAt = Date.now();
|
|
755
1087
|
opened.child.on('exit', () => {
|
|
756
1088
|
const alive = Date.now() - launchedAt;
|
|
757
|
-
// Edge --app= often spawns a process that immediately hands its URL
|
|
758
|
-
// off to an existing Edge profile process group and exits — our
|
|
759
|
-
// child handle dies milliseconds after creation. Treat any exit
|
|
760
|
-
// inside the first 5s as a hand-off, not a real close.
|
|
761
1089
|
if (alive < 5000) {
|
|
762
1090
|
console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
|
|
763
1091
|
return;
|
|
764
1092
|
}
|
|
765
|
-
// Defer the kill decision by one full frontend ping cycle (~12s,
|
|
766
|
-
// matching the 10s heartbeat cadence below). Any heartbeat that
|
|
767
|
-
// arrives AFTER this moment must be from a DIFFERENT client (the
|
|
768
|
-
// closing browser couldn't ping after it died) — so a hosted-tab
|
|
769
|
-
// user is still around and we should stay alive.
|
|
770
1093
|
const closedAt = Date.now();
|
|
771
1094
|
setTimeout(() => {
|
|
772
1095
|
if (lastHeartbeat > closedAt + 100) {
|
|
@@ -779,13 +1102,6 @@ function openInBrowser(url, mode) {
|
|
|
779
1102
|
console.log('[ccsm] tied to browser window — close it to stop ccsm');
|
|
780
1103
|
}
|
|
781
1104
|
|
|
782
|
-
// Heartbeat watchdog · only activated when launched via bin/ccsm.js
|
|
783
|
-
// (CCSM_LAUNCHER=1). Catches cases the primary mechanism misses: the
|
|
784
|
-
// browser was killed forcibly, msedge crashed without a clean exit, or
|
|
785
|
-
// the user opened the URL in tab-mode in their own browser instead of
|
|
786
|
-
// the chromeless app window. We don't kill until we've seen at least
|
|
787
|
-
// one heartbeat — that way a freshly-booted server with no client yet
|
|
788
|
-
// doesn't suicide.
|
|
789
1105
|
if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
790
1106
|
setInterval(() => {
|
|
791
1107
|
if (!heartbeatSeen) return;
|
|
@@ -795,8 +1111,6 @@ function openInBrowser(url, mode) {
|
|
|
795
1111
|
}, 30_000);
|
|
796
1112
|
console.log('[ccsm] heartbeat watchdog active');
|
|
797
1113
|
}
|
|
798
|
-
|
|
799
|
-
startSnapshotLoop();
|
|
800
1114
|
})().catch((err) => {
|
|
801
1115
|
console.error('startup failed:', err);
|
|
802
1116
|
process.exit(1);
|