@bakapiano/ccsm 0.22.6 → 0.22.8

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 (61) hide show
  1. package/CLAUDE.md +521 -540
  2. package/README.md +186 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +36 -139
  5. package/lib/codexSeed.js +126 -183
  6. package/lib/config.js +277 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/persistedSessions.js +179 -139
  10. package/lib/tunnel.js +621 -621
  11. package/lib/webTerminal.js +225 -225
  12. package/lib/winPath.js +1 -1
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +154 -154
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +546 -546
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2347 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +349 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +28 -0
  42. package/public/js/components/useDragSort.js +67 -67
  43. package/public/js/dialog.js +67 -67
  44. package/public/js/icons.js +212 -212
  45. package/public/js/main.js +296 -296
  46. package/public/js/pages/AboutPage.js +90 -90
  47. package/public/js/pages/ConfigurePage.js +730 -713
  48. package/public/js/pages/LaunchPage.js +403 -421
  49. package/public/js/pages/RemotePage.js +743 -743
  50. package/public/js/pages/SessionsPage.js +54 -54
  51. package/public/js/state.js +335 -335
  52. package/public/js/util.js +1 -1
  53. package/scripts/dev.js +149 -149
  54. package/scripts/install.js +153 -153
  55. package/scripts/restart-helper.js +96 -96
  56. package/scripts/upgrade-helper.js +687 -687
  57. package/server.js +1748 -1817
  58. package/lib/localCliSessions.js +0 -519
  59. package/public/js/components/AdoptModal.js +0 -261
  60. package/public/manifest.webmanifest +0 -25
  61. package/public/setup/index.html +0 -567
package/CLAUDE.md CHANGED
@@ -1,543 +1,524 @@
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 |
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
+ │ ├── /ws/terminal/:id (PTY)
37
+ │ ├── /api/version /api/upgrade
38
+ │ ├── /api/heartbeat /api/health
39
+ └── /api/shutdown
40
+ └───────────────────────────────────────┘
41
+ ```
42
+
43
+ **Version routing.** GH Pages root (`/ccsm/`) hosts a tiny static
44
+ router (`pages-root/index.html`) that probes `localhost:7777/api/health`
45
+ and redirects to `./<backend.version>/`. Each release publishes a fresh
46
+ `/ccsm/<X.Y.Z>/` subdir; old ones stay forever via the workflow's
47
+ `keep_files: true`. Result: a 1:1 frontend↔backend version pin, no
48
+ semver-compat logic, and old backends keep working indefinitely.
49
+
50
+ Each per-version frontend has its version baked into a `<meta
51
+ name="ccsm-frontend-version">` at deploy time (injected by the GH Pages
52
+ workflow). On boot it re-fetches `/api/health` and bounces back through
53
+ the router via `location.replace('../')` if the backend has since been
54
+ upgraded.
55
+
56
+ When the backend is offline the router itself shows a "Start ccsm" UI
57
+ with a `ccsm://start` link (same protocol-handler trick we already
58
+ register at install time). No need to redirect to a stale version.
59
+
60
+ **Dev mode.** When running from a checkout (`__dirname` not under
61
+ `node_modules`), the backend ALSO serves `public/` so contributors can
62
+ iterate at `localhost:7777/` without pushing. In dev there's no
63
+ `<meta>` tag the version guard no-ops.
64
+
65
+ ## Run
66
+
67
+ ```powershell
68
+ # install once
69
+ npm install -g @bakapiano/ccsm
70
+
71
+ # then anywhere
72
+ ccsm
73
+ ```
74
+
75
+ `ccsm` opens the version router in a chromeless Edge `--app=` window.
76
+ Terminal returns immediately (the server is spawned detached). Close
77
+ the window server saves a final snapshot of state and exits within
78
+ ~12s.
79
+
80
+ If you don't want the auto-opened window (e.g. you live in the PWA),
81
+ just visit `https://bakapiano.github.io/ccsm/` when backend is
82
+ down you see the inline OfflineBanner with a **Start ccsm** button.
83
+
84
+ Default port `7777`, default workDir `~/ccsm-workspaces`. Config +
85
+ state live at `~/.ccsm/` (override with `CCSM_HOME=<path>`). All
86
+ settings editable through the Configure page
87
+ (`~/.ccsm/config.json` on disk). Notable knobs:
88
+
89
+ - `port` (default `7777`) — preferred listen port. If taken, ccsm tries `+1..+9` then asks the OS for any free port.
90
+ - `resumeMode` (default `latest`) — `latest` uses each CLI's `resumeLatestArgs`; `picker` uses `resumePickerArgs`.
91
+ - `clis` array of CLI definitions. Built-ins for `claude`, `codex`, `copilot`; users can add `other` CLIs with custom `command`, `args`, `resumeLatestArgs`, `resumePickerArgs`, `shell` (direct/pwsh/cmd).
92
+ - `defaultCliId` — which CLI the Launch page pre-selects.
93
+
94
+ ## ccsm:// protocol · "wake on click"
95
+
96
+ The hosted frontend can't spawn processes (sandboxed). For "click to
97
+ wake backend" we register a per-user URL protocol handler on Windows:
98
+
99
+ ```
100
+ HKCU\Software\Classes\ccsm\shell\open\command
101
+ → wscript.exe "<LOCALAPPDATA>\ccsm\launcher.vbs" "%1"
102
+ ```
103
+
104
+ `launcher.vbs` uses `Shell.Run(..., 0, False)` — windowstyle 0 means the
105
+ spawned `ccsm.cmd` runs **completely hidden**. No console flash. The
106
+ `.cmd` goes through `bin/ccsm.js`, which detects `ccsm://start` in argv,
107
+ spawns `server.js` detached with `CCSM_NO_BROWSER=1`, and exits.
108
+
109
+ The router's "Start ccsm" button (and OfflineBanner inside each
110
+ per-version frontend) is just `<a href="ccsm://start">`. First click
111
+ triggers a one-time Windows confirmation dialog ("Open ccsm.cmd?");
112
+ ticking "Always allow" makes it silent thereafter.
113
+
114
+ postinstall (`scripts/install.js`) registers the protocol
115
+ unconditionally on Windows — including npx-cache installs. The path
116
+ stored in the registry points at whatever `ccsm.cmd` location npm gave
117
+ us (`<prefix>/ccsm.cmd` from `npm config get prefix`).
118
+
119
+ ## In-app upgrade
120
+
121
+ About page surfaces the installed version, polls
122
+ `registry.npmjs.org/@bakapiano%2Fccsm/latest` (cached 30 min) for the
123
+ latest published version, and offers an **Upgrade** button when newer.
124
+ `POST /api/upgrade` spawns `npm i -g @bakapiano/ccsm@latest` detached,
125
+ then on success spawns a fresh `ccsm` (also detached) and
126
+ gracefulShutdowns. The OfflineBanner appears briefly; the router then
127
+ picks up the new version on its next probe.
128
+
129
+ The `target` field is regex-validated (`/^[a-z0-9.+\-^~]+$/i`) before
130
+ the spawn npm install doesn't shell out, but defends against argv
131
+ weirdness regardless. Concurrent calls return `409`.
132
+
133
+ ## Lifecycle
134
+
135
+ Single `gracefulShutdown(reason)` function in `server.js` is the only
136
+ exit path. It kills any PTY children, then `process.exit(0)`. Every
137
+ trigger funnels here:
138
+
139
+ | trigger | path |
140
+ |---|---|
141
+ | auto-spawned browser window closes | `child.on('exit')` — see smart-kill below |
142
+ | `POST /api/shutdown` | from npm uninstall, from launcher's auto-upgrade |
143
+ | `POST /api/upgrade` after install completes | self-restart |
144
+ | SIGINT / SIGTERM | OS signals |
145
+ | heartbeat watchdog timeout | 90s with no heartbeat, only when launched via `bin/ccsm.js` |
146
+
147
+ **Smart browser-exit**: when the spawned browser child dies, we don't
148
+ kill immediately. Two filters:
149
+
150
+ 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.
151
+ 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.
152
+
153
+ Frontend heartbeat cadence is 10s (in `main.js`), so one full cycle
154
+ fits inside the 12s decision window.
155
+
156
+ Environment overrides:
157
+ - `CCSM_KEEP_ALIVE=1` → disable both browser-exit hook and heartbeat watchdog. For automation hosts.
158
+ - `CCSM_LAUNCHER=1` → set by `bin/ccsm.js` when it spawns the server; enables the heartbeat watchdog.
159
+ - `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.
160
+ - `CCSM_NO_DEV=1` → suppress dev-mode features (static serving, hot-reload SSE) even when running from a checkout.
161
+
162
+ ## Sessions: persisted and resumed
163
+
164
+ There's **one source of truth**: `~/.ccsm/sessions.json`, managed by
165
+ `lib/persistedSessions.js`. Every session ccsm starts goes in there
166
+ with `{ id, cliId, cwd, workspace, title, folderId, repos,
167
+ status, … }`. The persisted `id` is ccsm-owned and matches the PTY id;
168
+ it is not an upstream CLI session id.
169
+
170
+ **Folder-level resume.** ccsm does not persist upstream session ids and
171
+ does not seed CLI transcript files. Resume launches the configured CLI
172
+ at the record's `cwd` and appends one of two folder-level templates:
173
+
174
+ - `resumeMode: 'latest'` -> `cli.resumeLatestArgs`
175
+ - `resumeMode: 'picker'` -> `cli.resumePickerArgs`
176
+
177
+ Built-ins default to:
178
+
179
+ - Claude: latest `--continue`, picker `--resume`
180
+ - Codex: latest `resume --last`, picker `resume`
181
+ - Copilot: latest `--continue`, picker `--resume`
182
+
183
+ User-added `other` CLIs can leave those arrays empty if they do not
184
+ support resume, or set either template explicitly.
185
+
186
+ **Duplicate records.** New sessions are keyed by normalized
187
+ `cliId + cwd`. If the user launches the same CLI in the same folder
188
+ again, ccsm reuses the existing record instead of creating a second
189
+ sidebar entry. Workspace allocation treats every persisted session with
190
+ a `cwd` as occupying that workspace until the session record is deleted.
191
+
192
+ **Auto-resume.** SessionsPage doesn't show a "Resume" button. On
193
+ mount, if the active session's status isn't `running`, it calls
194
+ `resumeSession()`. `resumeSession()` in `api.js` keeps a per-id
195
+ in-flight Map so the same call from Sidebar.onClick and the
196
+ SessionsPage effect collapse into a single backend hit.
197
+
198
+ ## Layout
199
+
200
+ ```
201
+ ccsm/
202
+ ├── server.js # Express + WebSocket; API-only in prod
203
+ ├── bin/ccsm.js # launcher · detach, wake-on-protocol,
204
+ │ # auto-upgrade-restart, first-run hint
205
+ ├── scripts/
206
+ │ ├── install.js # postinstall · ccsm:// + launcher.vbs
207
+ │ └── uninstall.js # preuninstall · cleanup + /api/shutdown
208
+ ├── lib/
209
+ │ ├── persistedSessions.js # ~/.ccsm/sessions.json — source of truth
210
+ │ ├── folders.js # ~/.ccsm/folders.json — sidebar tree
211
+ │ ├── codexSeed.js # Codex CODEX_HOME probe + bundled light theme install
212
+ │ ├── workspace.js # ws-N allocation under workDir, repo clones
213
+ │ ├── webTerminal.js # in-process PTY pool · node-pty + WebSocket
214
+ ├── jsonStore.js # shared keyed-JSON store factory
215
+ │ └── config.js # loadConfig / saveConfig + DATA_DIR
216
+ ├── pages-root/ # pushed to GH Pages /
217
+ ├── index.html # version router · probe localhost, redirect
218
+ │ ├── manifest.webmanifest # PWA · stable id, start_url: ./
219
+ │ └── favicon.svg
220
+ └── public/ # pushed to GH Pages /<version>/
221
+ ├── index.html # workflow injects <meta ccsm-frontend-version>
222
+ ├── manifest.webmanifest # per-version (links back to root scope)
223
+ ├── favicon.svg
224
+ ├── js/
225
+ ├── backend.js # httpBase() / wsBase() same-origin local, cross-origin hosted
226
+ │ ├── main.js # boot · version guard · clock · heartbeat
227
+ │ ├── state.js # signals
228
+ │ ├── api.js # fetch wrapper + loaders + dedup-aware resumeSession
229
+ ├── streaming.js # NDJSON clone-progress stream
230
+ ├── dialog.js · toast.js # ccsmConfirm / ccsmPrompt / setToast
231
+ │ ├── html.js · icons.js · util.js
232
+ │ ├── components/
233
+ │ ├── App.js · Sidebar.js · PageTitleBar.js
234
+ │ │ ├── ServerStatus.js · Toast.js · OfflineBanner.js · DialogHost.js
235
+ │ │ ├── Card.js · Modal.js · Popover.js · Picker.js · EntityFormModal.js
236
+ │ │ ├── DirectoryPicker.js
237
+ │ │ ├── ProgressList.js · TerminalView.js · useDragSort.js
238
+ │ └── pages/
239
+ ├── SessionsPage.js · LaunchPage.js
240
+ ├── ConfigurePage.js · AboutPage.js
241
+ └── css/ # 12 focused stylesheets
242
+ ├── tokens.css · base.css · layout.css
243
+ ├── sidebar.css · cards.css · forms.css
244
+ ├── widgets.css · feedback.css · modal.css
245
+ ├── terminals.css · wco.css · responsive.css
246
+
247
+ ~/.ccsm/ # or $CCSM_HOME
248
+ ├── config.json # source of truth
249
+ ├── sessions.json # persisted sessions (ccsm id, cliId, cwd, …)
250
+ ├── folders.json # folder tree
251
+ ├── server.log # detached-server stdout/stderr
252
+ ├── .first-run-shown # marker so launcher only prints PWA hint once
253
+ └── browser-profile/ # Edge/Chrome --user-data-dir when browserMode=app
254
+
255
+ %LOCALAPPDATA%/ccsm/
256
+ └── launcher.vbs # silent ccsm:// dispatcher (written by postinstall)
257
+
258
+ HKCU\Software\Classes\ccsm # URL protocol registration
259
+ ```
260
+
261
+ On first run, if a legacy `<repo>/data/` directory exists and `~/.ccsm/`
262
+ is empty, `lib/config.js` copies the old data over (one-time,
263
+ idempotent).
264
+
265
+ ## Locked-in design decisions
266
+
267
+ **Single in-app terminal, no `wt`.** PTYs run in-process via node-pty
268
+ and stream to xterm.js over `/ws/terminal/:id`. We dropped the
269
+ `wt`-per-session, focus-by-HWND, snapshot-of-live-claudes layer
270
+ entirely too platform-specific and the web terminal handles
271
+ everything the old path did.
272
+
273
+ **Workspace = folder holding multiple repo clones.** Each `ws-N` under
274
+ `workDir` contains a subdirectory per cloned repo. CLIs launch at the
275
+ single selected repo's directory; with zero or multiple repos selected,
276
+ they launch at the workspace root so selected repos are sibling folders.
277
+
278
+ **Workspace naming.** Auto-allocated names are `ws-1`, `ws-2`, …
279
+ (lowest free integer). Hand-named folders under `workDir` are still
280
+ picked up.
281
+
282
+ **Frontend trusts the backend's capability advertisement.**
283
+ `/api/capabilities` returns `{ webTerminal: true|false, ... }`. The
284
+ frontend uses ONLY features the backend says it has. Breaking changes
285
+ ship a new `/ccsm/<X.Y.Z>/` frontend; the router pins users to the
286
+ matching version.
287
+
288
+ **One source of truth for cross-origin.** `public/js/backend.js`
289
+ exports `httpBase()` and `wsBase()`. Localhost same-origin (empty
290
+ base). Anything else → `http://localhost:7777`. CORS on the backend
291
+ allows `https://bakapiano.github.io` only never `*`.
292
+
293
+ ## API surface
294
+
295
+ | Method | Path | Purpose |
296
+ |---|---|---|
297
+ | GET / PUT | `/api/config` | read / replace config |
298
+ | GET | `/api/sessions` | list persisted sessions |
299
+ | PUT | `/api/sessions/:id` | rename / move to folder |
313
300
  | DELETE | `/api/sessions/:id` | kill PTY + drop record |
314
- | POST | `/api/sessions/:id/switch-cli` | change the persisted `cliId` for future resumes; current and target CLI must share `type` |
301
+ | POST | `/api/sessions/:id/switch-cli` | change the persisted `cliId` for future resumes |
315
302
  | POST | `/api/sessions/:id/stop` | kill the live PTY but keep the record; sets `manualStopped:true` so UI won't auto-resume |
316
303
  | POST | `/api/sessions/new` | body `{cliId, cwd?, repos?, folderId?, title?}` — NDJSON stream (workspace · clone-progress · launched) |
317
- | POST | `/api/sessions/:id/resume` | re-spawn at `cwd` with `cli.resumeIdArgs <id>` (fallback `resumeArgs`) |
318
- | GET | `/api/cli-sessions/:type` | scan disk for unimported `claude`/`codex`/`copilot` sessions |
319
- | POST | `/api/sessions/adopt` | body `{cliId, cliSessionId, cwd, title?, folderId?}` — create a `status:exited` record with `cliSessionId` pre-set |
320
- | GET | `/api/folders` · POST `/api/folders` · PUT/DELETE `/api/folders/:id` · POST `/api/folders/reorder` | folder CRUD |
321
- | GET | `/api/workspaces` | workspaces under workDir with repo clone status + in-use flag |
322
- | GET | `/api/browse` | directory browser for the Launch page workdir picker |
323
- | GET | `/api/version` | `{ current, latest, updateAvailable, fetchedAt, cached, error? }` (npm registry cached 30 min, `?refresh=1` to bust) |
324
- | POST | `/api/upgrade` | body `{target?}` — `npm i -g @bakapiano/ccsm@<target>` then self-restart |
325
- | GET | `/api/capabilities` | `{ webTerminal: bool, ... }` for frontend feature gating |
326
- | GET | `/api/health` | `{ ok, pid, version, name }` used by router probe + heartbeat |
327
- | POST | `/api/heartbeat` | called every 10s by the frontend; feeds lifecycle decisions |
328
- | POST | `/api/spawn-browser` | open another browser window into the running server (used by `bin/ccsm.js` for auto-upgrade-restart) |
329
- | POST | `/api/shutdown` | gracefulShutdown used by uninstall + auto-upgrade |
330
- | WS | `/ws/terminal/:id` | xterm.js bridge to a PTY in the webTerminal pool |
331
- | GET (dev) | `/api/dev/ping` · `/api/dev/reload` | hot-reload SSE (only when running from a checkout) |
332
-
333
- `/api/sessions/new` streams **NDJSON** (one JSON object per line). Event
334
- types: `workspace`, `clone-start`, `clone-progress` (phase/percent/
335
- current/total/detail), `clone-line` (raw git stderr line when not a
336
- progress line), `clone-end`, `launched`, `done`. The frontend reads it
337
- with `fetch().body.getReader()` + `TextDecoder` and updates per-repo
338
- progress bars live.
339
-
340
- **WebSocket Origin check**: same allow-list as CORS. The upgrade handler
341
- rejects any Origin not in `ALLOWED_ORIGINS` (plus localhost/127.0.0.1).
342
- Browsers always send Origin on WS upgrades.
343
-
344
- ## Non-obvious gotchas
345
-
346
- **Pre-assigned UUID needs either a flag OR a writable transcript dir.**
347
- `newSessionIdArgs` works two ways: native flag (claude, copilot via
348
- `--session-id`) or seeded transcript file (codex via `resume <id>` +
349
- `lib/codexSeed.js`). User-added "other" CLIs without either get no
350
- pre-assignment and fall back to `resumeArgs` (`--continue` / equivalent)
351
- on relaunch they just won't have a captured upstream id.
352
-
353
- **Adopt is atomic.** `persistedSessions.create()` accepts `status` +
354
- `cliSessionId` so the adopt endpoint writes the record in a single
355
- file write rather than `create({running})` + `update({exited,
356
- cliSessionId})`. The two-write form had a window where a concurrent
357
- GET /api/sessions could see `running` with no live PTY, fooling the
358
- sidebar's "skip resume if running" guard.
359
-
360
- **Auto-resume dedup is module-level in api.js.** Sidebar.onClick and
361
- SessionsPage's effect can both fire for the same exited session in the
362
- same tick. `resumeSession()` keeps a per-id in-flight `Map` so the
363
- second caller awaits the first one's promise instead of issuing a
364
- second `/resume`.
365
-
366
- **Heartbeat watchdog only when launched.** Set via `CCSM_LAUNCHER=1` by
367
- `bin/ccsm.js`. If you start `server.js` directly (e.g. dev), the
368
- 90-second timeout doesn't apply convenient when stepping through
369
- code, but you have to ctrl-c yourself when done.
370
-
371
- **ccsm:// silent dispatch.** Direct registration of `ccsm.cmd` as the
372
- protocol handler causes a brief console window flash (cmd hosts the
373
- .cmd file). The wscript.exe + .vbs wrapper avoids it entirely — wscript
374
- is a Windows-subsystem host (no console) and `Shell.Run(..., 0, False)`
375
- launches the target hidden. The `.vbs` is generated at install time
376
- with the correct ccsm.cmd path baked in.
377
-
378
- **Edge --app handoff race.** When the user has an existing Edge profile
379
- process running, `--app=URL --user-data-dir=DIR` against the same DIR
380
- may cause the new msedge.exe to immediately exit after handing the URL
381
- off to the existing process. Our child handle dies milliseconds after
382
- spawn. The lifecycle hook ignores any browser-child exit inside the
383
- first 5s for exactly this reason.
384
-
385
- ## Frontend design language
386
-
387
- The UI deliberately copies **claude.ai's** calm light aesthetic — warm
388
- cream surfaces, generous spacing, soft borders, **no orange highlights**.
389
- The brand orange `#b3614a` survives only in the brand mark / wordmark
390
- dot. Every other "highlight" use (selection, focus rings, dirty
391
- indicators, progress bars, page-actions banner) is ink/gray.
392
-
393
- **Palette** (CSS vars in `public/css/tokens.css`):
394
- - `--bg` `#faf9f5` warm cream page background
395
- - `--bg-elev` `#ffffff` card surfaces
396
- - `--sidebar-bg` `#faf9f5` (same as `--bg`, single continuous surface)
397
- - `--border` `#e8e3d5`
398
- - `--ink` `#1a1815` body text (warm near-black, also used for terminal background)
399
- - `--ink-mid` / `--ink-muted` / `--ink-faint`
400
- - `--accent` `#b3614a` desaturated terracotta brand only
401
- - Status: green `#4a8a4a` idle · blue `#4a73a5` busy (pulsing) · red `#b73f3f` danger
402
-
403
- **Type**:
404
- - Body / headings: **Geist** (Google Fonts, 300–700).
405
- - Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags.
406
- - Always `font-variant-numeric: tabular-nums` on numeric cells.
407
-
408
- **Buttons**:
409
- - `.action` (default) white bg, ink-mid border, ink text.
410
- - `.action.primary` black ink bg, white text. The "do this" CTA.
411
- - `.action.subtle` transparent bg, light border.
412
- - `.action.danger` filled red bg + white text.
413
-
414
- **Layout**:
415
- - Sidebar (collapsible, ~232px ↔ ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`)
416
- - brand mark + `CCSM.` wordmark
417
- - tabs: Sessions / Launch / Configure / About
418
- - folder tree of persisted sessions (drag-sortable)
419
- - Page-title bar: title on the left, server-status pill + Refresh button on the right
420
- - Top-right control group uses fixed `min-height: 28px` and `border-radius: 999px` so server-status + Refresh align as a coherent control row
421
-
422
- **No emoji in the UI** unless the user typed it. Use inline SVG icons
423
- everywhere (line stroke, 1.5–2px) so they take `currentColor`.
424
-
425
- **PWA + WCO**:
426
- - `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.
427
- - `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.
428
- - 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.
429
-
430
- ## Versioning
431
-
432
- The hosted frontend lives at `https://bakapiano.github.io/ccsm/`. The
433
- deploy workflow publishes two things to gh-pages on every push to main:
434
-
435
- 1. `pages-root/` `/` (the router, plus root PWA manifest)
436
- 2. `public/` `/<pkg.version>/` (the per-version frontend; workflow injects `<meta name="ccsm-frontend-version">` at build time)
437
-
438
- Old version dirs stay forever (`keep_files: true`), so a user on an
439
- older backend keeps loading the matching frontend until they upgrade.
440
-
441
- `bin/ccsm.js` does auto-upgrade-restart: when the user runs `ccsm` and
442
- the installed package version differs from a running backend, it POSTs
443
- `/api/shutdown` to the old, waits for the port to free, then spawns a
444
- fresh server. So `npm i -g @bakapiano/ccsm@latest && ccsm` is one
445
- seamless step. From the frontend, the About page's Upgrade button
446
- achieves the same thing without leaving the browser.
447
-
448
- ### Release process
449
-
450
- **Do not release or push without explicit permission from the user.**
451
- This means: don't run `git push`, don't run `npm version`, don't run
452
- `gh release edit --draft=false`, don't commit + tag in one breath
453
- because "the fix is ready". Wait for the user to say so. The instinct
454
- to ship is wrong here — a half-baked release on the public npm registry
455
- is much worse than a few minutes of waiting.
456
-
457
- Three artifacts ship per release: a git tag, a GitHub Release, and an
458
- npm publish. The whole thing is CI-driven — you never `npm publish`
459
- locally but it requires you to drive three steps in order:
460
-
461
- 1. **Commit + bump + push (local).** Stage everything, write a release
462
- commit, then bump + tag + push:
463
-
464
- ```powershell
465
- git add -A
466
- git commit -m "vX.Y.Z: <one-line summary>
467
-
468
- <body>
469
-
470
- Co-Authored-By: Claude ..."
471
- npm --prefix . version <patch|minor|major> -m "v%s"
472
- git push origin main
473
- git push origin vX.Y.Z
474
- ```
475
-
476
- `npm version` writes the new version into `package.json` +
477
- `package-lock.json`, creates its OWN commit, and tags it. The
478
- `--prefix .` is needed on Windows where bare `npm version` errors on
479
- the global `%APPDATA%\npm\package.json`. Push BOTH `main` and the
480
- tag pushing only main skips the tag-triggered draft-release
481
- workflow.
482
-
483
- 2. **Tag-push fires two workflows automatically:**
484
- - `Deploy frontend to GitHub Pages` → publishes `pages-root/` → `/`
485
- and `public/` → `/<X.Y.Z>/` on `gh-pages`. Old `/<X.Y.Z>/`
486
- subdirs stay forever (`keep_files: true`).
487
- - `Draft GitHub Release on tag push` → creates a **draft** release
488
- for `vX.Y.Z`.
489
-
490
- 3. **Publish the draft (manual one-liner):**
491
-
492
- ```powershell
493
- gh release edit vX.Y.Z --draft=false
494
- ```
495
-
496
- This flips the draft to "published", which fires the third workflow
497
- `Publish to npm` using the `NPM_TOKEN` repo secret with
498
- provenance. The runner needs ~30s; verify with
499
- `gh run watch <run-id> --exit-status` or just refresh npmjs.com.
500
-
501
- The reason for the draft step instead of auto-publishing on tag push:
502
- gives you a chance to abort a half-baked tag (delete the draft +
503
- `git push --delete origin vX.Y.Z`) before it lands on the public
504
- registry.
505
-
506
- ### Why we don't publish from the local box
507
-
508
- `npm publish` from a dev machine works in principle but skips
509
- provenance attestation (the sigstore + GitHub OIDC binding that npm
510
- displays as a "Provenance" badge on the package page). CI has the OIDC
511
- token; you don't. Local publish also wouldn't have the consistent
512
- runner state, so reproducible-build claims fall apart. The pipeline
513
- exists; use it.
514
-
515
- ## Cross-platform
516
-
517
- Today: Windows-first.
518
-
519
- Cross-platform-clean already:
520
- - Frontend (pure web)
521
- - Router page (pure HTML/JS)
522
- - `bin/ccsm.js` (pure node)
523
- - `lib/webTerminal.js` (node-pty handles platform)
524
- - `lib/persistedSessions.js`, `lib/folders.js`, `lib/config.js`, `lib/jsonStore.js`, `lib/localCliSessions.js`, `lib/workspace.js` (fs only)
525
- - `server.js` Express + ws
526
-
527
- Windows-specific (need ports for Mac/Linux):
528
- - `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`.
529
- - The `--app=` browser detection and PATH-merge in `server.js` are Windows-shaped (Edge first, registry HKCU\Environment for PATH).
530
-
531
- Pattern for adding a platform: `switch (process.platform)` at each
532
- entry point in those files. Each platform branch is roughly 50-100
533
- lines.
534
-
535
- ## Extending
536
-
537
- When adding features, the natural extension points:
538
- - **New REST routes**: `server.js` (keep under `/api/*`, use the `asyncH` wrapper, decide if it needs CORS by being in the allow-list).
539
- - **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`.
540
- - **Persistent user data**: drop a JSON file under `~/.ccsm/` and use `lib/jsonStore.js`'s factory.
541
- - **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`.
542
- - **A capability**: advertise via `/api/capabilities`. Frontend gates UI on `caps.<feature>`.
543
- - **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.
304
+ | POST | `/api/sessions/:id/resume` | re-spawn at record `cwd` with the configured latest/picker resume args |
305
+ | GET | `/api/folders` · POST `/api/folders` · PUT/DELETE `/api/folders/:id` · POST `/api/folders/reorder` | folder CRUD |
306
+ | GET | `/api/workspaces` | workspaces under workDir with repo clone status + in-use flag |
307
+ | GET | `/api/browse` | directory browser for the Launch page workdir picker |
308
+ | GET | `/api/version` | `{ current, latest, updateAvailable, fetchedAt, cached, error? }` (npm registry cached 30 min, `?refresh=1` to bust) |
309
+ | POST | `/api/upgrade` | body `{target?}` `npm i -g @bakapiano/ccsm@<target>` then self-restart |
310
+ | GET | `/api/capabilities` | `{ webTerminal: bool, ... }` for frontend feature gating |
311
+ | GET | `/api/health` | `{ ok, pid, version, name }` — used by router probe + heartbeat |
312
+ | POST | `/api/heartbeat` | called every 10s by the frontend; feeds lifecycle decisions |
313
+ | POST | `/api/spawn-browser` | open another browser window into the running server (used by `bin/ccsm.js` for auto-upgrade-restart) |
314
+ | POST | `/api/shutdown` | gracefulShutdown used by uninstall + auto-upgrade |
315
+ | WS | `/ws/terminal/:id` | xterm.js bridge to a PTY in the webTerminal pool |
316
+ | GET (dev) | `/api/dev/ping` · `/api/dev/reload` | hot-reload SSE (only when running from a checkout) |
317
+
318
+ `/api/sessions/new` streams **NDJSON** (one JSON object per line). Event
319
+ types: `workspace`, `clone-start`, `clone-progress` (phase/percent/
320
+ current/total/detail), `clone-line` (raw git stderr line when not a
321
+ progress line), `clone-end`, `launched`, `done`. The frontend reads it
322
+ with `fetch().body.getReader()` + `TextDecoder` and updates per-repo
323
+ progress bars live.
324
+
325
+ **WebSocket Origin check**: same allow-list as CORS. The upgrade handler
326
+ rejects any Origin not in `ALLOWED_ORIGINS` (plus localhost/127.0.0.1).
327
+ Browsers always send Origin on WS upgrades.
328
+
329
+ ## Non-obvious gotchas
330
+
331
+ **Resume is cwd-scoped.** ccsm assumes the upstream CLI can find the
332
+ right conversation from the current working directory when handed its
333
+ latest/picker resume command. We deliberately do not persist or replay
334
+ upstream session UUIDs.
335
+
336
+ **Workspace reservation is record-scoped.** A stopped session still owns
337
+ its workspace until the session record is deleted. This keeps auto
338
+ allocation from reusing `ws-N` and later resuming an older session in a
339
+ folder that has been repurposed.
340
+
341
+ **Auto-resume dedup is module-level in api.js.** Sidebar.onClick and
342
+ SessionsPage's effect can both fire for the same exited session in the
343
+ same tick. `resumeSession()` keeps a per-id in-flight `Map` so the
344
+ second caller awaits the first one's promise instead of issuing a
345
+ second `/resume`.
346
+
347
+ **Heartbeat watchdog only when launched.** Set via `CCSM_LAUNCHER=1` by
348
+ `bin/ccsm.js`. If you start `server.js` directly (e.g. dev), the
349
+ 90-second timeout doesn't apply convenient when stepping through
350
+ code, but you have to ctrl-c yourself when done.
351
+
352
+ **ccsm:// silent dispatch.** Direct registration of `ccsm.cmd` as the
353
+ protocol handler causes a brief console window flash (cmd hosts the
354
+ .cmd file). The wscript.exe + .vbs wrapper avoids it entirely — wscript
355
+ is a Windows-subsystem host (no console) and `Shell.Run(..., 0, False)`
356
+ launches the target hidden. The `.vbs` is generated at install time
357
+ with the correct ccsm.cmd path baked in.
358
+
359
+ **Edge --app handoff race.** When the user has an existing Edge profile
360
+ process running, `--app=URL --user-data-dir=DIR` against the same DIR
361
+ may cause the new msedge.exe to immediately exit after handing the URL
362
+ off to the existing process. Our child handle dies milliseconds after
363
+ spawn. The lifecycle hook ignores any browser-child exit inside the
364
+ first 5s for exactly this reason.
365
+
366
+ ## Frontend design language
367
+
368
+ The UI deliberately copies **claude.ai's** calm light aesthetic warm
369
+ cream surfaces, generous spacing, soft borders, **no orange highlights**.
370
+ The brand orange `#b3614a` survives only in the brand mark / wordmark
371
+ dot. Every other "highlight" use (selection, focus rings, dirty
372
+ indicators, progress bars, page-actions banner) is ink/gray.
373
+
374
+ **Palette** (CSS vars in `public/css/tokens.css`):
375
+ - `--bg` `#faf9f5` warm cream page background
376
+ - `--bg-elev` `#ffffff` card surfaces
377
+ - `--sidebar-bg` `#faf9f5` (same as `--bg`, single continuous surface)
378
+ - `--border` `#e8e3d5`
379
+ - `--ink` `#1a1815` body text (warm near-black, also used for terminal background)
380
+ - `--ink-mid` / `--ink-muted` / `--ink-faint`
381
+ - `--accent` `#b3614a` desaturated terracotta brand only
382
+ - Status: green `#4a8a4a` idle · blue `#4a73a5` busy (pulsing) · red `#b73f3f` danger
383
+
384
+ **Type**:
385
+ - Body / headings: **Geist** (Google Fonts, 300–700).
386
+ - Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags.
387
+ - Always `font-variant-numeric: tabular-nums` on numeric cells.
388
+
389
+ **Buttons**:
390
+ - `.action` (default) — white bg, ink-mid border, ink text.
391
+ - `.action.primary` black ink bg, white text. The "do this" CTA.
392
+ - `.action.subtle` transparent bg, light border.
393
+ - `.action.danger` filled red bg + white text.
394
+
395
+ **Layout**:
396
+ - Sidebar (collapsible, ~232px ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`)
397
+ - brand mark + `CCSM.` wordmark
398
+ - tabs: Sessions / Launch / Configure / About
399
+ - folder tree of persisted sessions (drag-sortable)
400
+ - Page-title bar: title on the left, server-status pill + Refresh button on the right
401
+ - Top-right control group uses fixed `min-height: 28px` and `border-radius: 999px` so server-status + Refresh align as a coherent control row
402
+
403
+ **No emoji in the UI** unless the user typed it. Use inline SVG icons
404
+ everywhere (line stroke, 1.5–2px) so they take `currentColor`.
405
+
406
+ **PWA + WCO**:
407
+ - `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.
408
+ - `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.
409
+ - 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.
410
+
411
+ ## Versioning
412
+
413
+ The hosted frontend lives at `https://bakapiano.github.io/ccsm/`. The
414
+ deploy workflow publishes two things to gh-pages on every push to main:
415
+
416
+ 1. `pages-root/` → `/` (the router, plus root PWA manifest)
417
+ 2. `public/` → `/<pkg.version>/` (the per-version frontend; workflow injects `<meta name="ccsm-frontend-version">` at build time)
418
+
419
+ Old version dirs stay forever (`keep_files: true`), so a user on an
420
+ older backend keeps loading the matching frontend until they upgrade.
421
+
422
+ `bin/ccsm.js` does auto-upgrade-restart: when the user runs `ccsm` and
423
+ the installed package version differs from a running backend, it POSTs
424
+ `/api/shutdown` to the old, waits for the port to free, then spawns a
425
+ fresh server. So `npm i -g @bakapiano/ccsm@latest && ccsm` is one
426
+ seamless step. From the frontend, the About page's Upgrade button
427
+ achieves the same thing without leaving the browser.
428
+
429
+ ### Release process
430
+
431
+ **Do not release or push without explicit permission from the user.**
432
+ This means: don't run `git push`, don't run `npm version`, don't run
433
+ `gh release edit --draft=false`, don't commit + tag in one breath
434
+ because "the fix is ready". Wait for the user to say so. The instinct
435
+ to ship is wrong here — a half-baked release on the public npm registry
436
+ is much worse than a few minutes of waiting.
437
+
438
+ Three artifacts ship per release: a git tag, a GitHub Release, and an
439
+ npm publish. The whole thing is CI-driven you never `npm publish`
440
+ locally but it requires you to drive three steps in order:
441
+
442
+ 1. **Commit + bump + push (local).** Stage everything, write a release
443
+ commit, then bump + tag + push:
444
+
445
+ ```powershell
446
+ git add -A
447
+ git commit -m "vX.Y.Z: <one-line summary>
448
+
449
+ <body>
450
+
451
+ Co-Authored-By: Claude ..."
452
+ npm --prefix . version <patch|minor|major> -m "v%s"
453
+ git push origin main
454
+ git push origin vX.Y.Z
455
+ ```
456
+
457
+ `npm version` writes the new version into `package.json` +
458
+ `package-lock.json`, creates its OWN commit, and tags it. The
459
+ `--prefix .` is needed on Windows where bare `npm version` errors on
460
+ the global `%APPDATA%\npm\package.json`. Push BOTH `main` and the
461
+ tag — pushing only main skips the tag-triggered draft-release
462
+ workflow.
463
+
464
+ 2. **Tag-push fires two workflows automatically:**
465
+ - `Deploy frontend to GitHub Pages` publishes `pages-root/` `/`
466
+ and `public/` → `/<X.Y.Z>/` on `gh-pages`. Old `/<X.Y.Z>/`
467
+ subdirs stay forever (`keep_files: true`).
468
+ - `Draft GitHub Release on tag push` → creates a **draft** release
469
+ for `vX.Y.Z`.
470
+
471
+ 3. **Publish the draft (manual one-liner):**
472
+
473
+ ```powershell
474
+ gh release edit vX.Y.Z --draft=false
475
+ ```
476
+
477
+ This flips the draft to "published", which fires the third workflow
478
+ — `Publish to npm` — using the `NPM_TOKEN` repo secret with
479
+ provenance. The runner needs ~30s; verify with
480
+ `gh run watch <run-id> --exit-status` or just refresh npmjs.com.
481
+
482
+ The reason for the draft step instead of auto-publishing on tag push:
483
+ gives you a chance to abort a half-baked tag (delete the draft +
484
+ `git push --delete origin vX.Y.Z`) before it lands on the public
485
+ registry.
486
+
487
+ ### Why we don't publish from the local box
488
+
489
+ `npm publish` from a dev machine works in principle but skips
490
+ provenance attestation (the sigstore + GitHub OIDC binding that npm
491
+ displays as a "Provenance" badge on the package page). CI has the OIDC
492
+ token; you don't. Local publish also wouldn't have the consistent
493
+ runner state, so reproducible-build claims fall apart. The pipeline
494
+ exists; use it.
495
+
496
+ ## Cross-platform
497
+
498
+ Today: Windows-first.
499
+
500
+ Cross-platform-clean already:
501
+ - Frontend (pure web)
502
+ - Router page (pure HTML/JS)
503
+ - `bin/ccsm.js` (pure node)
504
+ - `lib/webTerminal.js` (node-pty handles platform)
505
+ - `lib/persistedSessions.js`, `lib/folders.js`, `lib/config.js`, `lib/jsonStore.js`, `lib/workspace.js` (fs only)
506
+ - `server.js` Express + ws
507
+
508
+ Windows-specific (need ports for Mac/Linux):
509
+ - `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`.
510
+ - The `--app=` browser detection and PATH-merge in `server.js` are Windows-shaped (Edge first, registry HKCU\Environment for PATH).
511
+
512
+ Pattern for adding a platform: `switch (process.platform)` at each
513
+ entry point in those files. Each platform branch is roughly 50-100
514
+ lines.
515
+
516
+ ## Extending
517
+
518
+ When adding features, the natural extension points:
519
+ - **New REST routes**: `server.js` (keep under `/api/*`, use the `asyncH` wrapper, decide if it needs CORS by being in the allow-list).
520
+ - **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`.
521
+ - **Persistent user data**: drop a JSON file under `~/.ccsm/` and use `lib/jsonStore.js`'s factory.
522
+ - **Different CLIs**: add a built-in to `DEFAULT_CLIS` in `lib/config.js` with `resumeLatestArgs` / `resumePickerArgs`, and add an icon to `public/js/icons.js`.
523
+ - **A capability**: advertise via `/api/capabilities`. Frontend gates UI on `caps.<feature>`.
524
+ - **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.