@bakapiano/ccsm 0.14.0 → 0.15.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.
Files changed (53) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +205 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +173 -165
  43. package/public/js/pages/ConfigurePage.js +513 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -158
  49. package/scripts/restart-helper.js +96 -0
  50. package/scripts/upgrade-helper.js +6 -1
  51. package/server.js +1282 -1254
  52. package/lib/cliSessionWatcher.js +0 -275
  53. package/public/manifest.webmanifest +0 -15
package/CLAUDE.md CHANGED
@@ -1,475 +1,474 @@
1
- # ccsm — Claude Code Session Manager
2
-
3
- A small Node/Express + Preact web tool that runs every Claude/Codex/
4
- Copilot CLI session inside a single web app. PTYs live in-process
5
- (node-pty), sessions persist across restarts, and `--resume <uuid>`
6
- reattaches to the exact upstream conversation.
7
-
8
- ## Why this exists
9
-
10
- When you're running 8–10 concurrent `claude` sessions across ad-hoc
11
- clones (`D:\proj`, `D:\proj2`, `…`, plus GUID worktree dirs), it's easy
12
- to lose track of which terminal is which session. ccsm gives an
13
- at-a-glance sidebar, organises sessions into folders, and `--resume`s
14
- each one in the same xterm.js panel.
15
-
16
- ## Architecture: hosted frontend + local backend
17
-
18
- The frontend is **not** bundled in the npm package — it's hosted on
19
- GitHub Pages and matched to your installed backend version through a
20
- version router.
21
-
22
- ```
23
- ┌── browser ────────────────────────────┐
24
- │ https://bakapiano.github.io/ccsm/ ← version router (tiny)
25
- │ ↓
26
- │ https://bakapiano.github.io/ccsm/X.Y.Z/ ← per-version frontend
27
- └────────────┬──────────────────────────┘
28
- │ fetch /api/* (CORS, allow-list)
29
- │ ws://localhost:7777/ws/*
30
-
31
- ┌── local backend ──────────────────────┐
32
- │ npm i -g @bakapiano/ccsm │
33
- │ ccsm │
34
- │ ├── /api/sessions /api/sessions/new │
35
- │ ├── /api/sessions/:id/resume │
36
- │ ├── /api/sessions/adopt │
37
- │ ├── /ws/terminal/:id (PTY) │
38
- │ ├── /api/version /api/upgrade │
39
- │ ├── /api/heartbeat /api/health │
40
- │ └── /api/shutdown │
41
- └───────────────────────────────────────┘
42
- ```
43
-
44
- **Version routing.** GH Pages root (`/ccsm/`) hosts a tiny static
45
- router (`pages-root/index.html`) that probes `localhost:7777/api/health`
46
- and redirects to `./<backend.version>/`. Each release publishes a fresh
47
- `/ccsm/<X.Y.Z>/` subdir; old ones stay forever via the workflow's
48
- `keep_files: true`. Result: a 1:1 frontend↔backend version pin, no
49
- semver-compat logic, and old backends keep working indefinitely.
50
-
51
- Each per-version frontend has its version baked into a `<meta
52
- name="ccsm-frontend-version">` at deploy time (injected by the GH Pages
53
- workflow). On boot it re-fetches `/api/health` and bounces back through
54
- the router via `location.replace('../')` if the backend has since been
55
- upgraded.
56
-
57
- When the backend is offline the router itself shows a "Start ccsm" UI
58
- with a `ccsm://start` link (same protocol-handler trick we already
59
- register at install time). No need to redirect to a stale version.
60
-
61
- **Dev mode.** When running from a checkout (`__dirname` not under
62
- `node_modules`), the backend ALSO serves `public/` so contributors can
63
- iterate at `localhost:7777/` without pushing. In dev there's no
64
- `<meta>` tag → the version guard no-ops.
65
-
66
- ## Run
67
-
68
- ```powershell
69
- # install once
70
- npm install -g @bakapiano/ccsm
71
-
72
- # then anywhere
73
- ccsm
74
- ```
75
-
76
- `ccsm` opens the version router in a chromeless Edge `--app=` window.
77
- Terminal returns immediately (the server is spawned detached). Close
78
- the window → server saves a final snapshot of state and exits within
79
- ~12s.
80
-
81
- If you don't want the auto-opened window (e.g. you live in the PWA),
82
- just visit `https://bakapiano.github.io/ccsm/` — when backend is
83
- down you see the inline OfflineBanner with a **Start ccsm** button.
84
-
85
- Default port `7777`, default workDir `~/ccsm-workspaces`. Config +
86
- state live at `~/.ccsm/` (override with `CCSM_HOME=<path>`). All
87
- settings editable through the Configure page
88
- (`~/.ccsm/config.json` on disk). Notable knobs:
89
-
90
- - `port` (default `7777`) — preferred listen port. If taken, ccsm tries `+1..+9` then asks the OS for any free port.
91
- - `browserMode` (default `app`) — `app` finds Edge or Chrome and spawns it with `--app=<url> --user-data-dir=<DATA_DIR>/browser-profile`. `tab` opens the default browser. `none` skips opening.
92
- - `clis` — array of CLI definitions. Built-ins for `claude`, `codex`, `copilot`; users can add `other` CLIs with custom `command`, `args`, `resumeArgs`, `resumeIdArgs`, `shell` (direct/pwsh/cmd).
93
- - `defaultCliId` — which CLI the Launch page pre-selects.
94
-
95
- ## ccsm:// protocol · "wake on click"
96
-
97
- The hosted frontend can't spawn processes (sandboxed). For "click to
98
- wake backend" we register a per-user URL protocol handler on Windows:
99
-
100
- ```
101
- HKCU\Software\Classes\ccsm\shell\open\command
102
- → wscript.exe "<LOCALAPPDATA>\ccsm\launcher.vbs" "%1"
103
- ```
104
-
105
- `launcher.vbs` uses `Shell.Run(..., 0, False)` — windowstyle 0 means the
106
- spawned `ccsm.cmd` runs **completely hidden**. No console flash. The
107
- `.cmd` goes through `bin/ccsm.js`, which detects `ccsm://start` in argv,
108
- spawns `server.js` detached with `CCSM_NO_BROWSER=1`, and exits.
109
-
110
- The router's "Start ccsm" button (and OfflineBanner inside each
111
- per-version frontend) is just `<a href="ccsm://start">`. First click
112
- triggers a one-time Windows confirmation dialog ("Open ccsm.cmd?");
113
- ticking "Always allow" makes it silent thereafter.
114
-
115
- postinstall (`scripts/install.js`) registers the protocol
116
- unconditionally on Windows — including npx-cache installs. The path
117
- stored in the registry points at whatever `ccsm.cmd` location npm gave
118
- us (`<prefix>/ccsm.cmd` from `npm config get prefix`).
119
-
120
- ## In-app upgrade
121
-
122
- About page surfaces the installed version, polls
123
- `registry.npmjs.org/@bakapiano%2Fccsm/latest` (cached 30 min) for the
124
- latest published version, and offers an **Upgrade** button when newer.
125
- `POST /api/upgrade` spawns `npm i -g @bakapiano/ccsm@latest` detached,
126
- then on success spawns a fresh `ccsm` (also detached) and
127
- gracefulShutdowns. The OfflineBanner appears briefly; the router then
128
- picks up the new version on its next probe.
129
-
130
- The `target` field is regex-validated (`/^[a-z0-9.+\-^~]+$/i`) before
131
- the spawn — npm install doesn't shell out, but defends against argv
132
- weirdness regardless. Concurrent calls return `409`.
133
-
134
- ## Lifecycle
135
-
136
- Single `gracefulShutdown(reason)` function in `server.js` is the only
137
- exit path. It kills any PTY children, then `process.exit(0)`. Every
138
- trigger funnels here:
139
-
140
- | trigger | path |
141
- |---|---|
142
- | auto-spawned browser window closes | `child.on('exit')` — see smart-kill below |
143
- | `POST /api/shutdown` | from npm uninstall, from launcher's auto-upgrade |
144
- | `POST /api/upgrade` after install completes | self-restart |
145
- | SIGINT / SIGTERM | OS signals |
146
- | heartbeat watchdog timeout | 90s with no heartbeat, only when launched via `bin/ccsm.js` |
147
-
148
- **Smart browser-exit**: when the spawned browser child dies, we don't
149
- kill immediately. Two filters:
150
-
151
- 1. **Fast-exit (<5s)** — Edge `--app=` often hands the URL off to an existing Edge profile process group and the spawned child dies milliseconds after creation. We ignore any exit inside the first 5s.
152
- 2. **Deferred multi-client check (12s)** — after a real close, wait 12s and check if any heartbeat arrived AFTER the close timestamp. If yes, a hosted-frontend tab (or another window) is keeping us busy, stay alive. If no, gracefulShutdown.
153
-
154
- Frontend heartbeat cadence is 10s (in `main.js`), so one full cycle
155
- fits inside the 12s decision window.
156
-
157
- Environment overrides:
158
- - `CCSM_KEEP_ALIVE=1` → disable both browser-exit hook and heartbeat watchdog. For automation hosts.
159
- - `CCSM_LAUNCHER=1` → set by `bin/ccsm.js` when it spawns the server; enables the heartbeat watchdog.
160
- - `CCSM_NO_BROWSER=1` → set by the launcher when handling a `ccsm://` click or by `/api/upgrade` self-respawn; suppresses the server's auto-open browser.
161
- - `CCSM_NO_DEV=1` → suppress dev-mode features (static serving, hot-reload SSE) even when running from a checkout.
162
-
163
- ## Sessions: persisted, adopted, resumed
164
-
165
- There's **one source of truth**: `~/.ccsm/sessions.json`, managed by
166
- `lib/persistedSessions.js`. Every session ccsm starts goes in there
167
- with `{ id, cliId, cwd, workspace, title, folderId, repos,
168
- cliSessionId, status, … }`. We don't enumerate `~/.claude/` or walk
169
- process trees anymore.
170
-
171
- **`cliSessionId` capture.** For `claude` / `codex` / `copilot`, on
172
- spawn we kick off `lib/cliSessionWatcher.js`. It polls the CLI's
173
- transcript directory (`~/.claude/projects/<slug>/`,
174
- `~/.codex/sessions/`, `~/.copilot/session-state/`) for a new file whose
175
- recorded cwd matches our spawn cwd, then stores its UUID into the
176
- record. Later, **resume** rewrites the args using the CLI's
177
- `resumeIdArgs` template (`['--resume', '<id>']`) so the upstream
178
- conversation reattaches precisely. Fallback when no UUID is captured:
179
- `cli.resumeArgs` (`--continue` / `resume --last`).
180
-
181
- Watcher lifecycle: 5-minute timeout. If no transcript ever appears,
182
- the ccsm record is removed (we assume the user closed the CLI before
183
- it persisted anything). The cleanup fn returned by `captureSessionId`
184
- is tracked in a module-scope `Map` keyed by ccsm session id, and torn
185
- down on PTY exit and on session delete — otherwise a still-running
186
- watcher could match a future session's transcript and stamp the wrong
187
- UUID onto a dead record.
188
-
189
- If the record already has a `cliSessionId` (typical for resume and
190
- **always** for `adopt`-imported sessions), we skip the watcher
191
- entirely there's nothing left to capture, and the timeout would
192
- wipe the record after 5 min.
193
-
194
- **Adopt.** "Import existing session" on the Launch page lists
195
- sessions found on disk (`/api/cli-sessions/:type`) and lets the user
196
- add one to ccsm with `/api/sessions/adopt`. The created record is
197
- born `status: 'exited'` with `cliSessionId` pre-set. Clicking it in
198
- the sidebar runs the normal resume flow which uses the captured id.
199
-
200
- **Auto-resume.** SessionsPage doesn't show a "Resume" button. On
201
- mount, if the active session's status isn't `running`, it calls
202
- `resumeSession()`. `resumeSession()` in `api.js` keeps a per-id
203
- in-flight Map so the same call from Sidebar.onClick and the
204
- SessionsPage effect collapse into a single backend hit.
205
-
206
- ## Layout
207
-
208
- ```
209
- ccsm/
210
- ├── server.js # Express + WebSocket; API-only in prod
211
- ├── bin/ccsm.js # launcher · detach, wake-on-protocol,
212
- │ # auto-upgrade-restart, first-run hint
213
- ├── scripts/
214
- ├── install.js # postinstall · ccsm:// + launcher.vbs
215
- │ └── uninstall.js # preuninstall · cleanup + /api/shutdown
216
- ├── lib/
217
- ├── persistedSessions.js # ~/.ccsm/sessions.json — source of truth
218
- │ ├── folders.js # ~/.ccsm/folders.json sidebar tree
219
- ├── localCliSessions.js # scan ~/.claude · ~/.codex · ~/.copilot
220
- ├── cliSessionWatcher.js # capture upstream session UUID after spawn
221
- │ ├── workspace.js # ws-N allocation under workDir, repo clones
222
- │ ├── webTerminal.js # in-process PTY pool · node-pty + WebSocket
223
- │ ├── jsonStore.js # shared keyed-JSON store factory
224
- └── config.js # loadConfig / saveConfig + DATA_DIR
225
- ├── pages-root/ # pushed to GH Pages /
226
- │ ├── index.html # version router · probe localhost, redirect
227
- │ ├── manifest.webmanifest # PWA · stable id, start_url: ./
228
- └── favicon.svg
229
- └── public/ # pushed to GH Pages /<version>/
230
- ├── index.html # workflow injects <meta ccsm-frontend-version>
231
- ├── manifest.webmanifest # per-version (links back to root scope)
232
- ├── favicon.svg
233
- ├── js/
234
- │ ├── backend.js # httpBase() / wsBase() same-origin local, cross-origin hosted
235
- ├── main.js # boot · version guard · clock · heartbeat
236
- ├── state.js # signals
237
- ├── api.js # fetch wrapper + loaders + dedup-aware resumeSession
238
- ├── streaming.js # NDJSON clone-progress stream
239
- │ ├── dialog.js · toast.js # ccsmConfirm / ccsmPrompt / setToast
240
- │ ├── html.js · icons.js · util.js
241
- │ ├── components/
242
- ├── App.js · Sidebar.js · PageTitleBar.js
243
- ├── ServerStatus.js · Toast.js · OfflineBanner.js · DialogHost.js
244
- ├── Card.js · Modal.js · Popover.js · Picker.js · EntityFormModal.js
245
- ├── DirectoryPicker.js · AdoptModal.js
246
- ├── ProgressList.js · TerminalView.js · useDragSort.js
247
- └── pages/
248
- ├── SessionsPage.js · LaunchPage.js
249
- ├── ConfigurePage.js · AboutPage.js
250
- └── css/ # 12 focused stylesheets
251
- ├── tokens.css · base.css · layout.css
252
- ├── sidebar.css · cards.css · forms.css
253
- ├── widgets.css · feedback.css · modal.css
254
- ├── terminals.css · wco.css · responsive.css
255
-
256
- ~/.ccsm/ # or $CCSM_HOME
257
- ├── config.json # source of truth
258
- ├── sessions.json # persisted sessions (id, cliSessionId, …)
259
- ├── folders.json # folder tree
260
- ├── server.log # detached-server stdout/stderr
261
- ├── .first-run-shown # marker so launcher only prints PWA hint once
262
- └── browser-profile/ # Edge/Chrome --user-data-dir when browserMode=app
263
-
264
- %LOCALAPPDATA%/ccsm/
265
- └── launcher.vbs # silent ccsm:// dispatcher (written by postinstall)
266
-
267
- HKCU\Software\Classes\ccsm # URL protocol registration
268
- ```
269
-
270
- On first run, if a legacy `<repo>/data/` directory exists and `~/.ccsm/`
271
- is empty, `lib/config.js` copies the old data over (one-time,
272
- idempotent).
273
-
274
- ## Locked-in design decisions
275
-
276
- **Single in-app terminal, no `wt`.** PTYs run in-process via node-pty
277
- and stream to xterm.js over `/ws/terminal/:id`. We dropped the
278
- `wt`-per-session, focus-by-HWND, snapshot-of-live-claudes layer
279
- entirely — too platform-specific and the web terminal handles
280
- everything the old path did.
281
-
282
- **Workspace = folder holding multiple repo clones.** Each `ws-N` under
283
- `workDir` contains a subdirectory per cloned repo. CLIs launch at the
284
- workspace root so all selected repos are sibling folders.
285
-
286
- **Workspace naming.** Auto-allocated names are `ws-1`, `ws-2`, …
287
- (lowest free integer). Hand-named folders under `workDir` are still
288
- picked up.
289
-
290
- **Frontend trusts the backend's capability advertisement.**
291
- `/api/capabilities` returns `{ webTerminal: true|false, ... }`. The
292
- frontend uses ONLY features the backend says it has. Breaking changes
293
- ship a new `/ccsm/<X.Y.Z>/` frontend; the router pins users to the
294
- matching version.
295
-
296
- **One source of truth for cross-origin.** `public/js/backend.js`
297
- exports `httpBase()` and `wsBase()`. Localhost same-origin (empty
298
- base). Anything else `http://localhost:7777`. CORS on the backend
299
- allows `https://bakapiano.github.io` only — never `*`.
300
-
301
- ## API surface
302
-
303
- | Method | Path | Purpose |
304
- |---|---|---|
305
- | GET / PUT | `/api/config` | read / replace config |
306
- | GET | `/api/sessions` | list persisted sessions |
307
- | PUT | `/api/sessions/:id` | rename / move to folder |
308
- | DELETE | `/api/sessions/:id` | kill PTY + drop record + stop any watcher |
309
- | POST | `/api/sessions/new` | body `{cliId, cwd?, repos?, folderId?, title?}` — NDJSON stream (workspace · clone-progress · launched) |
310
- | POST | `/api/sessions/:id/resume` | re-spawn at `cwd` with `cli.resumeIdArgs <id>` (fallback `resumeArgs`) |
311
- | GET | `/api/cli-sessions/:type` | scan disk for unimported `claude`/`codex`/`copilot` sessions |
312
- | POST | `/api/sessions/adopt` | body `{cliId, cliSessionId, cwd, title?, folderId?}` — create a `status:exited` record with `cliSessionId` pre-set |
313
- | GET | `/api/folders` · POST `/api/folders` · PUT/DELETE `/api/folders/:id` · POST `/api/folders/reorder` | folder CRUD |
314
- | GET | `/api/workspaces` | workspaces under workDir with repo clone status + in-use flag |
315
- | GET | `/api/browse` | directory browser for the Launch page workdir picker |
316
- | GET | `/api/version` | `{ current, latest, updateAvailable, fetchedAt, cached, error? }` (npm registry cached 30 min, `?refresh=1` to bust) |
317
- | POST | `/api/upgrade` | body `{target?}` — `npm i -g @bakapiano/ccsm@<target>` then self-restart |
318
- | GET | `/api/capabilities` | `{ webTerminal: bool, ... }` for frontend feature gating |
319
- | GET | `/api/health` | `{ ok, pid, version, name }` used by router probe + heartbeat |
320
- | POST | `/api/heartbeat` | called every 10s by the frontend; feeds lifecycle decisions |
321
- | POST | `/api/spawn-browser` | open another browser window into the running server (used by `bin/ccsm.js` for auto-upgrade-restart) |
322
- | POST | `/api/shutdown` | gracefulShutdownused by uninstall + auto-upgrade |
323
- | WS | `/ws/terminal/:id` | xterm.js bridge to a PTY in the webTerminal pool |
324
- | GET (dev) | `/api/dev/ping` · `/api/dev/reload` | hot-reload SSE (only when running from a checkout) |
325
-
326
- `/api/sessions/new` streams **NDJSON** (one JSON object per line). Event
327
- types: `workspace`, `clone-start`, `clone-progress` (phase/percent/
328
- current/total/detail), `clone-line` (raw git stderr line when not a
329
- progress line), `clone-end`, `launched`, `done`. The frontend reads it
330
- with `fetch().body.getReader()` + `TextDecoder` and updates per-repo
331
- progress bars live.
332
-
333
- **WebSocket Origin check**: same allow-list as CORS. The upgrade handler
334
- rejects any Origin not in `ALLOWED_ORIGINS` (plus localhost/127.0.0.1).
335
- Browsers always send Origin on WS upgrades.
336
-
337
- ## Non-obvious gotchas
338
-
339
- **`firstJsonField` close-event race.** `lib/cliSessionWatcher.js` reads
340
- the first few lines of the upstream CLI's transcript to extract its
341
- `cwd` field. `readline.close()` synchronously emits `'close'` on
342
- Windows, which would re-enter `done(null)` and clobber a freshly
343
- resolved value. Guarded by a `settled` flag.
344
-
345
- **Watcher rejected entries are NOT memoised by mtime.** Earlier
346
- implementations cached "this file was old at first poll, skip forever".
347
- Wrong — `claude` *appends* to an existing transcript on `--resume`, so
348
- the file's mtime later crosses `spawnAt` and the watcher must
349
- re-evaluate. Only `cwd`-mismatch rejections are memoised (those are
350
- stable per file).
351
-
352
- **Adopt is atomic.** `persistedSessions.create()` accepts `status` +
353
- `cliSessionId` so the adopt endpoint writes the record in a single
354
- file write rather than `create({running})` + `update({exited,
355
- cliSessionId})`. The two-write form had a window where a concurrent
356
- GET /api/sessions could see `running` with no live PTY, fooling the
357
- sidebar's "skip resume if running" guard.
358
-
359
- **Auto-resume dedup is module-level in api.js.** Sidebar.onClick and
360
- SessionsPage's effect can both fire for the same exited session in the
361
- same tick. `resumeSession()` keeps a per-id in-flight `Map` so the
362
- second caller awaits the first one's promise instead of issuing a
363
- second `/resume`.
364
-
365
- **Heartbeat watchdog only when launched.** Set via `CCSM_LAUNCHER=1` by
366
- `bin/ccsm.js`. If you start `server.js` directly (e.g. dev), the
367
- 90-second timeout doesn't apply convenient when stepping through
368
- code, but you have to ctrl-c yourself when done.
369
-
370
- **ccsm:// silent dispatch.** Direct registration of `ccsm.cmd` as the
371
- protocol handler causes a brief console window flash (cmd hosts the
372
- .cmd file). The wscript.exe + .vbs wrapper avoids it entirely — wscript
373
- is a Windows-subsystem host (no console) and `Shell.Run(..., 0, False)`
374
- launches the target hidden. The `.vbs` is generated at install time
375
- with the correct ccsm.cmd path baked in.
376
-
377
- **Edge --app handoff race.** When the user has an existing Edge profile
378
- process running, `--app=URL --user-data-dir=DIR` against the same DIR
379
- may cause the new msedge.exe to immediately exit after handing the URL
380
- off to the existing process. Our child handle dies milliseconds after
381
- spawn. The lifecycle hook ignores any browser-child exit inside the
382
- first 5s for exactly this reason.
383
-
384
- ## Frontend design language
385
-
386
- The UI deliberately copies **claude.ai's** calm light aesthetic — warm
387
- cream surfaces, generous spacing, soft borders, **no orange highlights**.
388
- The brand orange `#b3614a` survives only in the brand mark / wordmark
389
- dot. Every other "highlight" use (selection, focus rings, dirty
390
- indicators, progress bars, page-actions banner) is ink/gray.
391
-
392
- **Palette** (CSS vars in `public/css/tokens.css`):
393
- - `--bg` `#faf9f5` warm cream page background
394
- - `--bg-elev` `#ffffff` card surfaces
395
- - `--sidebar-bg` `#faf9f5` (same as `--bg`, single continuous surface)
396
- - `--border` `#e8e3d5`
397
- - `--ink` `#1a1815` body text (warm near-black, also used for terminal background)
398
- - `--ink-mid` / `--ink-muted` / `--ink-faint`
399
- - `--accent` `#b3614a` desaturated terracotta brand only
400
- - Status: green `#4a8a4a` idle · blue `#4a73a5` busy (pulsing) · red `#b73f3f` danger
401
-
402
- **Type**:
403
- - Body / headings: **Geist** (Google Fonts, 300–700).
404
- - Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags.
405
- - Always `font-variant-numeric: tabular-nums` on numeric cells.
406
-
407
- **Buttons**:
408
- - `.action` (default) white bg, ink-mid border, ink text.
409
- - `.action.primary` — black ink bg, white text. The "do this" CTA.
410
- - `.action.subtle` — transparent bg, light border.
411
- - `.action.danger` — filled red bg + white text.
412
-
413
- **Layout**:
414
- - Sidebar (collapsible, ~232px ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`)
415
- - brand mark + `CCSM.` wordmark
416
- - tabs: Sessions / Launch / Configure / About
417
- - folder tree of persisted sessions (drag-sortable)
418
- - Page-title bar: title on the left, server-status pill + Refresh button on the right
419
- - Top-right control group uses fixed `min-height: 28px` and `border-radius: 999px` so server-status + Refresh align as a coherent control row
420
-
421
- **No emoji in the UI** unless the user typed it. Use inline SVG icons
422
- everywhere (line stroke, 1.5–2px) so they take `currentColor`.
423
-
424
- **PWA + WCO**:
425
- - `display_override: ["window-controls-overlay", "standalone"]` in the manifest. When installed and launched as PWA, the title bar's middle is reclaimed; only OS controls float top-right.
426
- - `public/css/wco.css` provides drag regions (`-webkit-app-region: drag`) on `.sidebar-brand`, `.page-head`, etc., unconditional. Interactive elements opt out via the no-drag block.
427
- - The root PWA manifest at `pages-root/manifest.webmanifest` has stable `id: /ccsm/` so installs survive across version-router redirects to new `/ccsm/<X.Y.Z>/` subdirs.
428
-
429
- ## Versioning
430
-
431
- The hosted frontend lives at `https://bakapiano.github.io/ccsm/`. The
432
- deploy workflow publishes two things to gh-pages on every push to main:
433
-
434
- 1. `pages-root/` → `/` (the router, plus root PWA manifest)
435
- 2. `public/` → `/<pkg.version>/` (the per-version frontend; workflow injects `<meta name="ccsm-frontend-version">` at build time)
436
-
437
- Old version dirs stay forever (`keep_files: true`), so a user on an
438
- older backend keeps loading the matching frontend until they upgrade.
439
-
440
- `bin/ccsm.js` does auto-upgrade-restart: when the user runs `ccsm` and
441
- the installed package version differs from a running backend, it POSTs
442
- `/api/shutdown` to the old, waits for the port to free, then spawns a
443
- fresh server. So `npm i -g @bakapiano/ccsm@latest && ccsm` is one
444
- seamless step. From the frontend, the About page's Upgrade button
445
- achieves the same thing without leaving the browser.
446
-
447
- ## Cross-platform
448
-
449
- Today: Windows-first.
450
-
451
- Cross-platform-clean already:
452
- - Frontend (pure web)
453
- - Router page (pure HTML/JS)
454
- - `bin/ccsm.js` (pure node)
455
- - `lib/webTerminal.js` (node-pty handles platform)
456
- - `lib/persistedSessions.js`, `lib/folders.js`, `lib/config.js`, `lib/jsonStore.js`, `lib/cliSessionWatcher.js`, `lib/localCliSessions.js`, `lib/workspace.js` (fs only)
457
- - `server.js` Express + ws
458
-
459
- Windows-specific (need ports for Mac/Linux):
460
- - `scripts/install.js` uses `reg.exe` and `wscript.exe`. Mac: write `Info.plist` with `CFBundleURLTypes`. Linux: write `~/.local/share/applications/ccsm.desktop` with `MimeType=x-scheme-handler/ccsm`.
461
- - The `--app=` browser detection and PATH-merge in `server.js` are Windows-shaped (Edge first, registry HKCU\Environment for PATH).
462
-
463
- Pattern for adding a platform: `switch (process.platform)` at each
464
- entry point in those files. Each platform branch is roughly 50-100
465
- lines.
466
-
467
- ## Extending
468
-
469
- When adding features, the natural extension points:
470
- - **New REST routes**: `server.js` (keep under `/api/*`, use the `asyncH` wrapper, decide if it needs CORS by being in the allow-list).
471
- - **Frontend page**: `public/js/pages/<Name>Page.js`, route in `App.js`, sidebar nav item in `Sidebar.js`, heading in `state.js`'s `TAB_HEADINGS`.
472
- - **Persistent user data**: drop a JSON file under `~/.ccsm/` and use `lib/jsonStore.js`'s factory.
473
- - **Different CLIs**: add a built-in to `DEFAULT_CLIS` in `lib/config.js`, an icon to `public/js/icons.js`, and (if the CLI persists transcripts on disk) a `profile` to `lib/cliSessionWatcher.js` and a list helper to `lib/localCliSessions.js`.
474
- - **A capability**: advertise via `/api/capabilities`. Frontend gates UI on `caps.<feature>`.
475
- - **Bumping the frontend**: just `npm version <patch|minor|major>` + push. The GH Pages workflow publishes to `/<new-version>/` and the router redirects users to it.
1
+ # ccsm — Claude Code Session Manager
2
+
3
+ A small Node/Express + Preact web tool that runs every Claude/Codex/
4
+ Copilot CLI session inside a single web app. PTYs live in-process
5
+ (node-pty), sessions persist across restarts, and `--resume <uuid>`
6
+ reattaches to the exact upstream conversation.
7
+
8
+ ## Why this exists
9
+
10
+ When you're running 8–10 concurrent `claude` sessions across ad-hoc
11
+ clones (`D:\proj`, `D:\proj2`, `…`, plus GUID worktree dirs), it's easy
12
+ to lose track of which terminal is which session. ccsm gives an
13
+ at-a-glance sidebar, organises sessions into folders, and `--resume`s
14
+ each one in the same xterm.js panel.
15
+
16
+ ## Architecture: hosted frontend + local backend
17
+
18
+ The frontend is **not** bundled in the npm package — it's hosted on
19
+ GitHub Pages and matched to your installed backend version through a
20
+ version router.
21
+
22
+ ```
23
+ ┌── browser ────────────────────────────┐
24
+ │ https://bakapiano.github.io/ccsm/ ← version router (tiny)
25
+ │ ↓
26
+ │ https://bakapiano.github.io/ccsm/X.Y.Z/ ← per-version frontend
27
+ └────────────┬──────────────────────────┘
28
+ │ fetch /api/* (CORS, allow-list)
29
+ │ ws://localhost:7777/ws/*
30
+
31
+ ┌── local backend ──────────────────────┐
32
+ │ npm i -g @bakapiano/ccsm │
33
+ │ ccsm │
34
+ │ ├── /api/sessions /api/sessions/new │
35
+ │ ├── /api/sessions/:id/resume │
36
+ │ ├── /api/sessions/adopt │
37
+ │ ├── /ws/terminal/:id (PTY) │
38
+ │ ├── /api/version /api/upgrade │
39
+ │ ├── /api/heartbeat /api/health │
40
+ │ └── /api/shutdown │
41
+ └───────────────────────────────────────┘
42
+ ```
43
+
44
+ **Version routing.** GH Pages root (`/ccsm/`) hosts a tiny static
45
+ router (`pages-root/index.html`) that probes `localhost:7777/api/health`
46
+ and redirects to `./<backend.version>/`. Each release publishes a fresh
47
+ `/ccsm/<X.Y.Z>/` subdir; old ones stay forever via the workflow's
48
+ `keep_files: true`. Result: a 1:1 frontend↔backend version pin, no
49
+ semver-compat logic, and old backends keep working indefinitely.
50
+
51
+ Each per-version frontend has its version baked into a `<meta
52
+ name="ccsm-frontend-version">` at deploy time (injected by the GH Pages
53
+ workflow). On boot it re-fetches `/api/health` and bounces back through
54
+ the router via `location.replace('../')` if the backend has since been
55
+ upgraded.
56
+
57
+ When the backend is offline the router itself shows a "Start ccsm" UI
58
+ with a `ccsm://start` link (same protocol-handler trick we already
59
+ register at install time). No need to redirect to a stale version.
60
+
61
+ **Dev mode.** When running from a checkout (`__dirname` not under
62
+ `node_modules`), the backend ALSO serves `public/` so contributors can
63
+ iterate at `localhost:7777/` without pushing. In dev there's no
64
+ `<meta>` tag → the version guard no-ops.
65
+
66
+ ## Run
67
+
68
+ ```powershell
69
+ # install once
70
+ npm install -g @bakapiano/ccsm
71
+
72
+ # then anywhere
73
+ ccsm
74
+ ```
75
+
76
+ `ccsm` opens the version router in a chromeless Edge `--app=` window.
77
+ Terminal returns immediately (the server is spawned detached). Close
78
+ the window → server saves a final snapshot of state and exits within
79
+ ~12s.
80
+
81
+ If you don't want the auto-opened window (e.g. you live in the PWA),
82
+ just visit `https://bakapiano.github.io/ccsm/` — when backend is
83
+ down you see the inline OfflineBanner with a **Start ccsm** button.
84
+
85
+ Default port `7777`, default workDir `~/ccsm-workspaces`. Config +
86
+ state live at `~/.ccsm/` (override with `CCSM_HOME=<path>`). All
87
+ settings editable through the Configure page
88
+ (`~/.ccsm/config.json` on disk). Notable knobs:
89
+
90
+ - `port` (default `7777`) — preferred listen port. If taken, ccsm tries `+1..+9` then asks the OS for any free port.
91
+ - `browserMode` (default `app`) — `app` finds Edge or Chrome and spawns it with `--app=<url> --user-data-dir=<DATA_DIR>/browser-profile`. `tab` opens the default browser. `none` skips opening.
92
+ - `clis` — array of CLI definitions. Built-ins for `claude`, `codex`, `copilot`; users can add `other` CLIs with custom `command`, `args`, `resumeArgs`, `resumeIdArgs`, `shell` (direct/pwsh/cmd).
93
+ - `defaultCliId` — which CLI the Launch page pre-selects.
94
+
95
+ ## ccsm:// protocol · "wake on click"
96
+
97
+ The hosted frontend can't spawn processes (sandboxed). For "click to
98
+ wake backend" we register a per-user URL protocol handler on Windows:
99
+
100
+ ```
101
+ HKCU\Software\Classes\ccsm\shell\open\command
102
+ → wscript.exe "<LOCALAPPDATA>\ccsm\launcher.vbs" "%1"
103
+ ```
104
+
105
+ `launcher.vbs` uses `Shell.Run(..., 0, False)` — windowstyle 0 means the
106
+ spawned `ccsm.cmd` runs **completely hidden**. No console flash. The
107
+ `.cmd` goes through `bin/ccsm.js`, which detects `ccsm://start` in argv,
108
+ spawns `server.js` detached with `CCSM_NO_BROWSER=1`, and exits.
109
+
110
+ The router's "Start ccsm" button (and OfflineBanner inside each
111
+ per-version frontend) is just `<a href="ccsm://start">`. First click
112
+ triggers a one-time Windows confirmation dialog ("Open ccsm.cmd?");
113
+ ticking "Always allow" makes it silent thereafter.
114
+
115
+ postinstall (`scripts/install.js`) registers the protocol
116
+ unconditionally on Windows — including npx-cache installs. The path
117
+ stored in the registry points at whatever `ccsm.cmd` location npm gave
118
+ us (`<prefix>/ccsm.cmd` from `npm config get prefix`).
119
+
120
+ ## In-app upgrade
121
+
122
+ About page surfaces the installed version, polls
123
+ `registry.npmjs.org/@bakapiano%2Fccsm/latest` (cached 30 min) for the
124
+ latest published version, and offers an **Upgrade** button when newer.
125
+ `POST /api/upgrade` spawns `npm i -g @bakapiano/ccsm@latest` detached,
126
+ then on success spawns a fresh `ccsm` (also detached) and
127
+ gracefulShutdowns. The OfflineBanner appears briefly; the router then
128
+ picks up the new version on its next probe.
129
+
130
+ The `target` field is regex-validated (`/^[a-z0-9.+\-^~]+$/i`) before
131
+ the spawn — npm install doesn't shell out, but defends against argv
132
+ weirdness regardless. Concurrent calls return `409`.
133
+
134
+ ## Lifecycle
135
+
136
+ Single `gracefulShutdown(reason)` function in `server.js` is the only
137
+ exit path. It kills any PTY children, then `process.exit(0)`. Every
138
+ trigger funnels here:
139
+
140
+ | trigger | path |
141
+ |---|---|
142
+ | auto-spawned browser window closes | `child.on('exit')` — see smart-kill below |
143
+ | `POST /api/shutdown` | from npm uninstall, from launcher's auto-upgrade |
144
+ | `POST /api/upgrade` after install completes | self-restart |
145
+ | SIGINT / SIGTERM | OS signals |
146
+ | heartbeat watchdog timeout | 90s with no heartbeat, only when launched via `bin/ccsm.js` |
147
+
148
+ **Smart browser-exit**: when the spawned browser child dies, we don't
149
+ kill immediately. Two filters:
150
+
151
+ 1. **Fast-exit (<5s)** — Edge `--app=` often hands the URL off to an existing Edge profile process group and the spawned child dies milliseconds after creation. We ignore any exit inside the first 5s.
152
+ 2. **Deferred multi-client check (12s)** — after a real close, wait 12s and check if any heartbeat arrived AFTER the close timestamp. If yes, a hosted-frontend tab (or another window) is keeping us busy, stay alive. If no, gracefulShutdown.
153
+
154
+ Frontend heartbeat cadence is 10s (in `main.js`), so one full cycle
155
+ fits inside the 12s decision window.
156
+
157
+ Environment overrides:
158
+ - `CCSM_KEEP_ALIVE=1` → disable both browser-exit hook and heartbeat watchdog. For automation hosts.
159
+ - `CCSM_LAUNCHER=1` → set by `bin/ccsm.js` when it spawns the server; enables the heartbeat watchdog.
160
+ - `CCSM_NO_BROWSER=1` → set by the launcher when handling a `ccsm://` click or by `/api/upgrade` self-respawn; suppresses the server's auto-open browser.
161
+ - `CCSM_NO_DEV=1` → suppress dev-mode features (static serving, hot-reload SSE) even when running from a checkout.
162
+
163
+ ## Sessions: persisted, adopted, resumed
164
+
165
+ There's **one source of truth**: `~/.ccsm/sessions.json`, managed by
166
+ `lib/persistedSessions.js`. Every session ccsm starts goes in there
167
+ with `{ id, cliId, cwd, workspace, title, folderId, repos,
168
+ cliSessionId, status, … }`. We don't enumerate `~/.claude/` or walk
169
+ process trees anymore.
170
+
171
+ **`cliSessionId` pre-assignment.** Every built-in CLI (claude, codex,
172
+ copilot) gets a UUID generated by `crypto.randomUUID()` at spawn time
173
+ and stamped into the persistedSessions record up front. How the UUID
174
+ makes it into the CLI's own state depends on what the CLI exposes:
175
+
176
+ - **claude / copilot** pass it via `--session-id <uuid>` (their
177
+ `newSessionIdArgs` template). The CLI creates its transcript file
178
+ under that exact UUID.
179
+ - **codex** no equivalent flag exists. Instead, `lib/codexSeed.js`
180
+ writes a fake rollout file at
181
+ `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl` containing
182
+ one `session_meta` line carrying the chosen id + spawn cwd, then we
183
+ spawn `codex resume <uuid>` so the first launch *is* a resume
184
+ against our seed. Codex appends real events to the same file from
185
+ there.
186
+
187
+ Either way, no polling, no race, no 5-minute timeout. **Resume** uses
188
+ the CLI's `resumeIdArgs` template (`['--resume', '<id>']` for
189
+ claude/copilot, `['resume', '<id>']` for codex) to reattach precisely.
190
+ There is no fallback path every ccsm-launched session has a captured
191
+ upstream id, so resume by id always applies. User-added "other" CLIs
192
+ must configure `newSessionIdArgs` + `resumeIdArgs` (or accept that
193
+ resume won't work).
194
+
195
+ For `adopt`-imported sessions the record is born with `cliSessionId`
196
+ already set (from disk scan), so resume uses `resumeIdArgs` directly.
197
+
198
+ **Adopt.** "Import existing session" on the Launch page lists
199
+ sessions found on disk (`/api/cli-sessions/:type`) and lets the user
200
+ add one to ccsm with `/api/sessions/adopt`. The created record is
201
+ born `status: 'exited'` with `cliSessionId` pre-set. Clicking it in
202
+ the sidebar runs the normal resume flow — which uses the captured id.
203
+
204
+ **Auto-resume.** SessionsPage doesn't show a "Resume" button. On
205
+ mount, if the active session's status isn't `running`, it calls
206
+ `resumeSession()`. `resumeSession()` in `api.js` keeps a per-id
207
+ in-flight Map so the same call from Sidebar.onClick and the
208
+ SessionsPage effect collapse into a single backend hit.
209
+
210
+ ## Layout
211
+
212
+ ```
213
+ ccsm/
214
+ ├── server.js # Express + WebSocket; API-only in prod
215
+ ├── bin/ccsm.js # launcher · detach, wake-on-protocol,
216
+ │ # auto-upgrade-restart, first-run hint
217
+ ├── scripts/
218
+ │ ├── install.js # postinstall · ccsm:// + launcher.vbs
219
+ └── uninstall.js # preuninstall · cleanup + /api/shutdown
220
+ ├── lib/
221
+ │ ├── persistedSessions.js # ~/.ccsm/sessions.json source of truth
222
+ │ ├── folders.js # ~/.ccsm/folders.json sidebar tree
223
+ │ ├── localCliSessions.js # scan ~/.claude · ~/.codex · ~/.copilot
224
+ ├── codexSeed.js # seed ~/.codex/sessions/.../rollout-*.jsonl
225
+ │ │ # so `codex resume <uuid>` works on launch 1
226
+ │ ├── workspace.js # ws-N allocation under workDir, repo clones
227
+ │ ├── webTerminal.js # in-process PTY pool · node-pty + WebSocket
228
+ ├── jsonStore.js # shared keyed-JSON store factory
229
+ └── config.js # loadConfig / saveConfig + DATA_DIR
230
+ ├── pages-root/ # pushed to GH Pages /
231
+ ├── index.html # version router · probe localhost, redirect
232
+ ├── manifest.webmanifest # PWA · stable id, start_url: ./
233
+ │ └── favicon.svg
234
+ └── public/ # pushed to GH Pages /<version>/
235
+ ├── index.html # workflow injects <meta ccsm-frontend-version>
236
+ ├── manifest.webmanifest # per-version (links back to root scope)
237
+ ├── favicon.svg
238
+ ├── js/
239
+ │ ├── backend.js # httpBase() / wsBase() same-origin local, cross-origin hosted
240
+ │ ├── main.js # boot · version guard · clock · heartbeat
241
+ │ ├── state.js # signals
242
+ │ ├── api.js # fetch wrapper + loaders + dedup-aware resumeSession
243
+ │ ├── streaming.js # NDJSON clone-progress stream
244
+ │ ├── dialog.js · toast.js # ccsmConfirm / ccsmPrompt / setToast
245
+ │ ├── html.js · icons.js · util.js
246
+ │ ├── components/
247
+ │ ├── App.js · Sidebar.js · PageTitleBar.js
248
+ ├── ServerStatus.js · Toast.js · OfflineBanner.js · DialogHost.js
249
+ ├── Card.js · Modal.js · Popover.js · Picker.js · EntityFormModal.js
250
+ │ │ ├── DirectoryPicker.js · AdoptModal.js
251
+ │ │ ├── ProgressList.js · TerminalView.js · useDragSort.js
252
+ │ └── pages/
253
+ ├── SessionsPage.js · LaunchPage.js
254
+ ├── ConfigurePage.js · AboutPage.js
255
+ └── css/ # 12 focused stylesheets
256
+ ├── tokens.css · base.css · layout.css
257
+ ├── sidebar.css · cards.css · forms.css
258
+ ├── widgets.css · feedback.css · modal.css
259
+ ├── terminals.css · wco.css · responsive.css
260
+
261
+ ~/.ccsm/ # or $CCSM_HOME
262
+ ├── config.json # source of truth
263
+ ├── sessions.json # persisted sessions (id, cliSessionId, …)
264
+ ├── folders.json # folder tree
265
+ ├── server.log # detached-server stdout/stderr
266
+ ├── .first-run-shown # marker so launcher only prints PWA hint once
267
+ └── browser-profile/ # Edge/Chrome --user-data-dir when browserMode=app
268
+
269
+ %LOCALAPPDATA%/ccsm/
270
+ └── launcher.vbs # silent ccsm:// dispatcher (written by postinstall)
271
+
272
+ HKCU\Software\Classes\ccsm # URL protocol registration
273
+ ```
274
+
275
+ On first run, if a legacy `<repo>/data/` directory exists and `~/.ccsm/`
276
+ is empty, `lib/config.js` copies the old data over (one-time,
277
+ idempotent).
278
+
279
+ ## Locked-in design decisions
280
+
281
+ **Single in-app terminal, no `wt`.** PTYs run in-process via node-pty
282
+ and stream to xterm.js over `/ws/terminal/:id`. We dropped the
283
+ `wt`-per-session, focus-by-HWND, snapshot-of-live-claudes layer
284
+ entirely too platform-specific and the web terminal handles
285
+ everything the old path did.
286
+
287
+ **Workspace = folder holding multiple repo clones.** Each `ws-N` under
288
+ `workDir` contains a subdirectory per cloned repo. CLIs launch at the
289
+ workspace root so all selected repos are sibling folders.
290
+
291
+ **Workspace naming.** Auto-allocated names are `ws-1`, `ws-2`,
292
+ (lowest free integer). Hand-named folders under `workDir` are still
293
+ picked up.
294
+
295
+ **Frontend trusts the backend's capability advertisement.**
296
+ `/api/capabilities` returns `{ webTerminal: true|false, ... }`. The
297
+ frontend uses ONLY features the backend says it has. Breaking changes
298
+ ship a new `/ccsm/<X.Y.Z>/` frontend; the router pins users to the
299
+ matching version.
300
+
301
+ **One source of truth for cross-origin.** `public/js/backend.js`
302
+ exports `httpBase()` and `wsBase()`. Localhost → same-origin (empty
303
+ base). Anything else `http://localhost:7777`. CORS on the backend
304
+ allows `https://bakapiano.github.io` only — never `*`.
305
+
306
+ ## API surface
307
+
308
+ | Method | Path | Purpose |
309
+ |---|---|---|
310
+ | GET / PUT | `/api/config` | read / replace config |
311
+ | GET | `/api/sessions` | list persisted sessions |
312
+ | PUT | `/api/sessions/:id` | rename / move to folder |
313
+ | DELETE | `/api/sessions/:id` | kill PTY + drop record |
314
+ | POST | `/api/sessions/new` | body `{cliId, cwd?, repos?, folderId?, title?}` NDJSON stream (workspace · clone-progress · launched) |
315
+ | POST | `/api/sessions/:id/resume` | re-spawn at `cwd` with `cli.resumeIdArgs <id>` (fallback `resumeArgs`) |
316
+ | GET | `/api/cli-sessions/:type` | scan disk for unimported `claude`/`codex`/`copilot` sessions |
317
+ | POST | `/api/sessions/adopt` | body `{cliId, cliSessionId, cwd, title?, folderId?}` — create a `status:exited` record with `cliSessionId` pre-set |
318
+ | GET | `/api/folders` · POST `/api/folders` · PUT/DELETE `/api/folders/:id` · POST `/api/folders/reorder` | folder CRUD |
319
+ | GET | `/api/workspaces` | workspaces under workDir with repo clone status + in-use flag |
320
+ | GET | `/api/browse` | directory browser for the Launch page workdir picker |
321
+ | GET | `/api/version` | `{ current, latest, updateAvailable, fetchedAt, cached, error? }` (npm registry cached 30 min, `?refresh=1` to bust) |
322
+ | POST | `/api/upgrade` | body `{target?}` `npm i -g @bakapiano/ccsm@<target>` then self-restart |
323
+ | GET | `/api/capabilities` | `{ webTerminal: bool, ... }` for frontend feature gating |
324
+ | GET | `/api/health` | `{ ok, pid, version, name }` used by router probe + heartbeat |
325
+ | POST | `/api/heartbeat` | called every 10s by the frontend; feeds lifecycle decisions |
326
+ | POST | `/api/spawn-browser` | open another browser window into the running server (used by `bin/ccsm.js` for auto-upgrade-restart) |
327
+ | POST | `/api/shutdown` | gracefulShutdown — used by uninstall + auto-upgrade |
328
+ | WS | `/ws/terminal/:id` | xterm.js bridge to a PTY in the webTerminal pool |
329
+ | GET (dev) | `/api/dev/ping` · `/api/dev/reload` | hot-reload SSE (only when running from a checkout) |
330
+
331
+ `/api/sessions/new` streams **NDJSON** (one JSON object per line). Event
332
+ types: `workspace`, `clone-start`, `clone-progress` (phase/percent/
333
+ current/total/detail), `clone-line` (raw git stderr line when not a
334
+ progress line), `clone-end`, `launched`, `done`. The frontend reads it
335
+ with `fetch().body.getReader()` + `TextDecoder` and updates per-repo
336
+ progress bars live.
337
+
338
+ **WebSocket Origin check**: same allow-list as CORS. The upgrade handler
339
+ rejects any Origin not in `ALLOWED_ORIGINS` (plus localhost/127.0.0.1).
340
+ Browsers always send Origin on WS upgrades.
341
+
342
+ ## Non-obvious gotchas
343
+
344
+ **Pre-assigned UUID needs either a flag OR a writable transcript dir.**
345
+ `newSessionIdArgs` works two ways: native flag (claude, copilot via
346
+ `--session-id`) or seeded transcript file (codex via `resume <id>` +
347
+ `lib/codexSeed.js`). User-added "other" CLIs without either get no
348
+ pre-assignment and fall back to `resumeArgs` (`--continue` / equivalent)
349
+ on relaunch they just won't have a captured upstream id.
350
+
351
+ **Adopt is atomic.** `persistedSessions.create()` accepts `status` +
352
+ `cliSessionId` so the adopt endpoint writes the record in a single
353
+ file write rather than `create({running})` + `update({exited,
354
+ cliSessionId})`. The two-write form had a window where a concurrent
355
+ GET /api/sessions could see `running` with no live PTY, fooling the
356
+ sidebar's "skip resume if running" guard.
357
+
358
+ **Auto-resume dedup is module-level in api.js.** Sidebar.onClick and
359
+ SessionsPage's effect can both fire for the same exited session in the
360
+ same tick. `resumeSession()` keeps a per-id in-flight `Map` so the
361
+ second caller awaits the first one's promise instead of issuing a
362
+ second `/resume`.
363
+
364
+ **Heartbeat watchdog only when launched.** Set via `CCSM_LAUNCHER=1` by
365
+ `bin/ccsm.js`. If you start `server.js` directly (e.g. dev), the
366
+ 90-second timeout doesn't apply convenient when stepping through
367
+ code, but you have to ctrl-c yourself when done.
368
+
369
+ **ccsm:// silent dispatch.** Direct registration of `ccsm.cmd` as the
370
+ protocol handler causes a brief console window flash (cmd hosts the
371
+ .cmd file). The wscript.exe + .vbs wrapper avoids it entirely — wscript
372
+ is a Windows-subsystem host (no console) and `Shell.Run(..., 0, False)`
373
+ launches the target hidden. The `.vbs` is generated at install time
374
+ with the correct ccsm.cmd path baked in.
375
+
376
+ **Edge --app handoff race.** When the user has an existing Edge profile
377
+ process running, `--app=URL --user-data-dir=DIR` against the same DIR
378
+ may cause the new msedge.exe to immediately exit after handing the URL
379
+ off to the existing process. Our child handle dies milliseconds after
380
+ spawn. The lifecycle hook ignores any browser-child exit inside the
381
+ first 5s for exactly this reason.
382
+
383
+ ## Frontend design language
384
+
385
+ The UI deliberately copies **claude.ai's** calm light aesthetic — warm
386
+ cream surfaces, generous spacing, soft borders, **no orange highlights**.
387
+ The brand orange `#b3614a` survives only in the brand mark / wordmark
388
+ dot. Every other "highlight" use (selection, focus rings, dirty
389
+ indicators, progress bars, page-actions banner) is ink/gray.
390
+
391
+ **Palette** (CSS vars in `public/css/tokens.css`):
392
+ - `--bg` `#faf9f5` warm cream page background
393
+ - `--bg-elev` `#ffffff` card surfaces
394
+ - `--sidebar-bg` `#faf9f5` (same as `--bg`, single continuous surface)
395
+ - `--border` `#e8e3d5`
396
+ - `--ink` `#1a1815` body text (warm near-black, also used for terminal background)
397
+ - `--ink-mid` / `--ink-muted` / `--ink-faint`
398
+ - `--accent` `#b3614a` desaturated terracotta brand only
399
+ - Status: green `#4a8a4a` idle · blue `#4a73a5` busy (pulsing) · red `#b73f3f` danger
400
+
401
+ **Type**:
402
+ - Body / headings: **Geist** (Google Fonts, 300–700).
403
+ - Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags.
404
+ - Always `font-variant-numeric: tabular-nums` on numeric cells.
405
+
406
+ **Buttons**:
407
+ - `.action` (default) — white bg, ink-mid border, ink text.
408
+ - `.action.primary` — black ink bg, white text. The "do this" CTA.
409
+ - `.action.subtle` — transparent bg, light border.
410
+ - `.action.danger` — filled red bg + white text.
411
+
412
+ **Layout**:
413
+ - Sidebar (collapsible, ~232px ↔ ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`)
414
+ - brand mark + `CCSM.` wordmark
415
+ - tabs: Sessions / Launch / Configure / About
416
+ - folder tree of persisted sessions (drag-sortable)
417
+ - Page-title bar: title on the left, server-status pill + Refresh button on the right
418
+ - Top-right control group uses fixed `min-height: 28px` and `border-radius: 999px` so server-status + Refresh align as a coherent control row
419
+
420
+ **No emoji in the UI** unless the user typed it. Use inline SVG icons
421
+ everywhere (line stroke, 1.5–2px) so they take `currentColor`.
422
+
423
+ **PWA + WCO**:
424
+ - `display_override: ["window-controls-overlay", "standalone"]` in the manifest. When installed and launched as PWA, the title bar's middle is reclaimed; only OS controls float top-right.
425
+ - `public/css/wco.css` provides drag regions (`-webkit-app-region: drag`) on `.sidebar-brand`, `.page-head`, etc., unconditional. Interactive elements opt out via the no-drag block.
426
+ - The root PWA manifest at `pages-root/manifest.webmanifest` has stable `id: /ccsm/` so installs survive across version-router redirects to new `/ccsm/<X.Y.Z>/` subdirs.
427
+
428
+ ## Versioning
429
+
430
+ The hosted frontend lives at `https://bakapiano.github.io/ccsm/`. The
431
+ deploy workflow publishes two things to gh-pages on every push to main:
432
+
433
+ 1. `pages-root/` → `/` (the router, plus root PWA manifest)
434
+ 2. `public/` → `/<pkg.version>/` (the per-version frontend; workflow injects `<meta name="ccsm-frontend-version">` at build time)
435
+
436
+ Old version dirs stay forever (`keep_files: true`), so a user on an
437
+ older backend keeps loading the matching frontend until they upgrade.
438
+
439
+ `bin/ccsm.js` does auto-upgrade-restart: when the user runs `ccsm` and
440
+ the installed package version differs from a running backend, it POSTs
441
+ `/api/shutdown` to the old, waits for the port to free, then spawns a
442
+ fresh server. So `npm i -g @bakapiano/ccsm@latest && ccsm` is one
443
+ seamless step. From the frontend, the About page's Upgrade button
444
+ achieves the same thing without leaving the browser.
445
+
446
+ ## Cross-platform
447
+
448
+ Today: Windows-first.
449
+
450
+ Cross-platform-clean already:
451
+ - Frontend (pure web)
452
+ - Router page (pure HTML/JS)
453
+ - `bin/ccsm.js` (pure node)
454
+ - `lib/webTerminal.js` (node-pty handles platform)
455
+ - `lib/persistedSessions.js`, `lib/folders.js`, `lib/config.js`, `lib/jsonStore.js`, `lib/localCliSessions.js`, `lib/workspace.js` (fs only)
456
+ - `server.js` Express + ws
457
+
458
+ Windows-specific (need ports for Mac/Linux):
459
+ - `scripts/install.js` uses `reg.exe` and `wscript.exe`. Mac: write `Info.plist` with `CFBundleURLTypes`. Linux: write `~/.local/share/applications/ccsm.desktop` with `MimeType=x-scheme-handler/ccsm`.
460
+ - The `--app=` browser detection and PATH-merge in `server.js` are Windows-shaped (Edge first, registry HKCU\Environment for PATH).
461
+
462
+ Pattern for adding a platform: `switch (process.platform)` at each
463
+ entry point in those files. Each platform branch is roughly 50-100
464
+ lines.
465
+
466
+ ## Extending
467
+
468
+ When adding features, the natural extension points:
469
+ - **New REST routes**: `server.js` (keep under `/api/*`, use the `asyncH` wrapper, decide if it needs CORS by being in the allow-list).
470
+ - **Frontend page**: `public/js/pages/<Name>Page.js`, route in `App.js`, sidebar nav item in `Sidebar.js`, heading in `state.js`'s `TAB_HEADINGS`.
471
+ - **Persistent user data**: drop a JSON file under `~/.ccsm/` and use `lib/jsonStore.js`'s factory.
472
+ - **Different CLIs**: add a built-in to `DEFAULT_CLIS` in `lib/config.js` (set `newSessionIdArgs` if the CLI accepts a pre-assigned UUID, `resumeIdArgs` for precise resume), an icon to `public/js/icons.js`, and (if the CLI persists transcripts on disk and you want adopt support) a list helper to `lib/localCliSessions.js`.
473
+ - **A capability**: advertise via `/api/capabilities`. Frontend gates UI on `caps.<feature>`.
474
+ - **Bumping the frontend**: just `npm version <patch|minor|major>` + push. The GH Pages workflow publishes to `/<new-version>/` and the router redirects users to it.