@bakapiano/ccsm 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,110 +1,284 @@
1
1
  # ccsm — Claude Code Session Manager
2
2
 
3
- A small Node/Express + vanilla-JS web tool that gives a single pane over all live Claude Code sessions on this machine, snapshots them, restores them through Windows Terminal, and launches new sessions inside isolated workspaces.
3
+ A small Node/Express + Preact web tool that gives a single pane over all
4
+ live Claude Code sessions on this machine, snapshots them, restores them
5
+ through Windows Terminal, and launches new sessions inside isolated
6
+ workspaces.
4
7
 
5
8
  ## Why this exists
6
9
 
7
- When you're running 8–10 concurrent `claude` sessions across ad-hoc clones (`D:\proj`, `D:\proj2`, `…`, plus GUID worktree dirs), it's easy to lose track of which terminal is which session. ccsm gives an at-a-glance list and a snapshot/restore safety net.
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 list and a snapshot/restore safety net.
14
+
15
+ ## Architecture: hosted frontend + local backend
16
+
17
+ The single most important fact about ccsm v0.8+ is that **the frontend
18
+ is no longer bundled into the npm package** in production. It lives at
19
+ `https://bakapiano.github.io/cssm/v1/`, served by GitHub Pages, deployed
20
+ via the workflow at `.github/workflows/deploy-pages.yml`.
21
+
22
+ ```
23
+ ┌── browser ────────────────────────────┐
24
+ │ https://bakapiano.github.io/cssm/v1/ ← static frontend
25
+ └────────────┬──────────────────────────┘
26
+ │ fetch /api/* (CORS, allow-list)
27
+ │ ws://localhost:7777/ws/*
28
+
29
+ ┌── local backend ──────────────────────┐
30
+ │ npm i -g @bakapiano/ccsm │
31
+ │ ccsm │
32
+ │ ├── /api/sessions /api/snapshot │
33
+ │ ├── /api/sessions/new (NDJSON) │
34
+ │ ├── /ws/terminal/:id (PTY) │
35
+ │ ├── /api/heartbeat /api/spawn-browser│
36
+ │ └── /api/health /api/shutdown │
37
+ └───────────────────────────────────────┘
38
+ ```
39
+
40
+ Why this split:
41
+ - Frontend can be updated independently — push to `main`, CI rebuilds GH Pages, every user hot-refreshes.
42
+ - No service worker complexity needed for "PWA loads even when backend is dead". The page itself is on GH Pages; the backend going down only loses `/api/*`, and the page handles that gracefully with an OfflineBanner.
43
+ - Cross-platform path forward — backend can be ported per-OS, but the frontend never has to be.
44
+ - Versioned at `/v1/` so future breaking changes ship a fresh `/v2/` while `/v1/` keeps serving the older clients.
45
+
46
+ In **dev mode** (running from a checkout — `__dirname` not under
47
+ `node_modules`), the backend ALSO serves `public/` so contributors can
48
+ iterate at `localhost:7777/` without pushing.
8
49
 
9
50
  ## Run
10
51
 
11
52
  ```powershell
12
- # from a checkout
13
- node server.js
53
+ # install once
54
+ npm install -g @bakapiano/ccsm
14
55
 
15
- # zero-install
16
- npx github:bakapiano/cssm
56
+ # then anywhere
57
+ ccsm
17
58
  ```
18
- Then open http://localhost:7777.
19
59
 
20
- Default port `7777`, default workDir `~/ccsm-workspaces`. Config + snapshots live at `~/.ccsm/` (override with `CCSM_HOME=<path>`). All settings editable through the Config panel (`~/.ccsm/config.json` on disk). Notable knobs:
60
+ `ccsm` opens the hosted frontend in a chromeless Edge `--app=` window.
61
+ Terminal returns immediately (the server is spawned detached). Close
62
+ the window → server saves a final snapshot and exits within ~12s.
21
63
 
22
- - `port` (default `7777`) — preferred listen port. If taken, ccsm tries `+1..+9` then asks the OS for any free port. The startup log prints the actual URL so you always see where it ended up.
23
- - `browserMode` (default `app`) — how to open the UI on server start. `app` finds Edge or Chrome and spawns it with `--app=<url> --user-data-dir=<DATA_DIR>/browser-profile` for a chromeless webview-style window (no tabs, no address bar). `tab` opens the default browser as a regular tab. `none` skips opening. Legacy `autoOpenBrowser: false` still maps to `none` for back-compat.
24
- - `claudeCommand` (default `"claude"`) — what gets `--resume`'d or freshly invoked inside the new terminal. Can be an exe (`claude`, `claude.exe`), a PowerShell alias or function (`ccp`), or any wrapper script — see `commandShell` below.
25
- - `terminal` — `wt` | `powershell` | `pwsh` | `cmd`. wt opens a fresh window per launch (`wt -w new` is set to defeat the "fold into existing window" setting some users have). The other three each spawn via `cmd /c start ... <shell>`.
26
- - `commandShell` (default `pwsh`) — only consulted when `terminal=wt`. Values `pwsh` / `powershell` wrap `claudeCommand` inside `<shell> -NoExit -NoLogo -Command "Set-Location ...; & '<cmd>' '<args>'..."` so PowerShell aliases / functions / profile-defined names (like `ccp` from `$PROFILE`) resolve. `none` runs the command directly via wt (raw `CreateProcess`) — fine if `claudeCommand` is an actual exe on PATH, broken for aliases. `pwsh` / `powershell` kinds already wrap natively so this knob doesn't affect them; `cmd` kind has no shell concept for aliases.
27
- - `autoFocusOnLaunch` (default true) — after every launch (new session, finder, resume, restore) the server takes an HWND snapshot of terminal windows, polls for a new HWND, and `SetForegroundWindow`s it. See gotcha below — wt is multi-window single-process, so we diff HWNDs not PIDs.
64
+ If you don't want the auto-opened window (e.g. you live in the PWA),
65
+ just visit `https://bakapiano.github.io/cssm/v1/` when backend is
66
+ down you see an OfflineBanner with a **Start ccsm** button.
28
67
 
29
- ## Layout
68
+ Default port `7777`, default workDir `~/ccsm-workspaces`. Config +
69
+ snapshots live at `~/.ccsm/` (override with `CCSM_HOME=<path>`). All
70
+ settings editable through the Configure panel
71
+ (`~/.ccsm/config.json` on disk). Notable knobs:
72
+
73
+ - `port` (default `7777`) — preferred listen port. If taken, ccsm tries `+1..+9` then asks the OS for any free port. The startup log prints the actual URL.
74
+ - `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.
75
+ - `claudeCommand` (default `"claude"`) — what gets `--resume`'d or freshly invoked. Can be an exe, alias, function, or wrapper script.
76
+ - `terminal` — `wt` | `powershell` | `pwsh` | `cmd`. wt opens a fresh window per launch (`wt -w new`).
77
+ - `commandShell` (default `pwsh`) — only consulted when `terminal=wt`. Wraps `claudeCommand` in `pwsh -NoExit -NoLogo -Command ...` so PowerShell aliases resolve.
78
+ - `autoFocusOnLaunch` (default true) — after every launch (new session, finder, resume, restore) the server takes an HWND snapshot of terminal windows, polls for a new HWND, and `SetForegroundWindow`s it.
79
+ - `finderPrompt` — initial message passed to the "Ask Claude to find a session" finder session.
80
+
81
+ ## ccsm:// protocol · "wake on click"
82
+
83
+ The hosted frontend can't spawn processes (sandboxed). For "click to
84
+ wake backend" we register a per-user URL protocol handler on Windows:
30
85
 
31
86
  ```
32
- D:\ccsm\
33
- ├── server.js # Express app + 60s auto-snapshot loop
34
- ├── lib\
35
- │ ├── sessions.js # reads ~/.claude/sessions/*.json + cross-checks live PIDs (tasklist)
36
- │ │ # + pulls last ai-title from ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
37
- │ │ # + listRecentSessions(limit, offset) returns paged {recent, total}
38
- │ ├── snapshot.js # save/load/rotate/restore — restore = launch one wt window per session
39
- │ ├── workspace.js # workspace = subfolder under workDir holding repo clones;
40
- │ │ # "in use" = any live session's cwd is at/under the workspace path
41
- │ ├── launcher.js # dispatches across terminal kinds (wt/powershell/pwsh/cmd);
42
- │ │ # path.resolve()s cwd; throws if cwd doesn't exist
43
- │ ├── focus.js # PowerShell + Win32 — listWindowsOf, focusByHwnd, focusByPid,
44
- │ │ # focusNewlyOpenedHwnd (HWND-diff for auto-focus on launch)
45
- │ ├── favorites.js # user-pinned sessions, ~/.ccsm/favorites.json keyed by sessionId
46
- │ └── config.js # loadConfig/saveConfig with defaults
47
- ├── public\
48
- │ ├── index.html, app.js, styles.css # vanilla, auto-refresh every 5s
49
- └── package.json # bin entry → `ccsm` (for npx github:bakapiano/cssm)
50
-
51
- ~/.ccsm/ # or $CCSM_HOME
52
- ├── config.json # source of truth
53
- ├── snapshot.json # latest snapshot, rewritten every 60s
54
- ├── snapshots/ # rotating history (default keep=30)
55
- ├── favorites.json # { [sessionId]: { sessionId, cwd, title, gitBranch, addedAt } }
56
- └── browser-profile/ # Edge/Chrome --user-data-dir when browserMode=app
87
+ HKCU\Software\Classes\ccsm\shell\open\command
88
+ wscript.exe "<LOCALAPPDATA>\ccsm\launcher.vbs" "%1"
57
89
  ```
58
90
 
59
- On first run, if a legacy `<repo>/data/` directory exists and `~/.ccsm/` is empty, `lib/config.js` copies the old data over (one-time, idempotent). The legacy dir is left in place clean up manually after verifying.
91
+ `launcher.vbs` uses `Shell.Run(..., 0, False)`windowstyle 0 means the
92
+ spawned `ccsm.cmd` runs **completely hidden**. No console flash. The
93
+ `.cmd` goes through `bin/ccsm.js`, which detects `ccsm://start` in argv,
94
+ spawns `server.js` detached with `CCSM_NO_BROWSER=1`, and exits.
60
95
 
61
- ## Locked-in design decisions
96
+ OfflineBanner's "Start ccsm" button is just an `<a href="ccsm://start">`.
97
+ First click triggers a one-time Windows confirmation dialog ("Open
98
+ ccsm.cmd?"); ticking "Always allow" makes it silent thereafter.
99
+
100
+ postinstall (`scripts/install.js`) registers the protocol unconditionally
101
+ on Windows — including npx-cache installs. The path stored in the
102
+ registry points at whatever `ccsm.cmd` location npm gave us
103
+ (`<prefix>/ccsm.cmd` from `npm config get prefix`).
104
+
105
+ ## Lifecycle
106
+
107
+ Single `gracefulShutdown(reason)` function in `server.js` is the only
108
+ exit path. It races `saveSnapshot()` against a 2s timeout, kills any
109
+ PTY children, then `process.exit(0)`. Every trigger funnels here:
110
+
111
+ | trigger | path |
112
+ |---|---|
113
+ | auto-spawned browser window closes | `child.on('exit')` — see smart-kill below |
114
+ | `POST /api/shutdown` | from npm uninstall, from launcher's auto-upgrade |
115
+ | SIGINT / SIGTERM | OS signals |
116
+ | heartbeat watchdog timeout | 90s with no heartbeat, only when launched via `bin/ccsm.js` |
117
+
118
+ **Smart browser-exit**: when the spawned browser child dies, we don't
119
+ kill immediately. Two filters:
120
+
121
+ 1. **Fast-exit (<5s)** — Edge `--app=` often hands the URL off to an
122
+ existing Edge profile process group and the spawned child dies
123
+ milliseconds after creation. We ignore any exit inside the first 5s.
124
+
125
+ 2. **Deferred multi-client check (12s)** — after a real close, wait 12s
126
+ and check if any heartbeat arrived AFTER the close timestamp. If
127
+ yes, a hosted-frontend tab (or another window) is keeping us busy,
128
+ stay alive. If no, gracefulShutdown.
62
129
 
63
- **Workspace = folder holding multiple repo clones.** Each `ws-N` under `workDir` contains a subdirectory per cloned repo. Claude launches at the workspace root so all selected repos are sibling folders.
130
+ Frontend heartbeat cadence is 10s (in `main.js`), so one full cycle
131
+ fits inside the 12s decision window.
132
+
133
+ Environment overrides:
134
+ - `CCSM_KEEP_ALIVE=1` → disable both browser-exit hook and heartbeat watchdog. For automation hosts.
135
+ - `CCSM_LAUNCHER=1` → set by `bin/ccsm.js` when it spawns the server; enables the heartbeat watchdog.
136
+ - `CCSM_NO_BROWSER=1` → set by the launcher when handling a `ccsm://` click; suppresses the server's auto-open browser.
137
+ - `CCSM_NO_DEV=1` → suppress dev-mode features (static serving, hot-reload SSE) even when running from a checkout.
138
+
139
+ ## Layout
64
140
 
65
141
  ```
66
- D:\ccsm-workspaces\
67
- ├── ws-1\
68
- ├── repo-a\
69
- └── repo-b\
70
- ├── ws-2\
71
- └── repo-a\
142
+ ccsm/
143
+ ├── server.js # Express + WebSocket; API-only in prod
144
+ ├── bin/ccsm.js # launcher · detach, wake-on-protocol,
145
+ # auto-upgrade-restart, first-run hint
146
+ ├── scripts/
147
+ ├── install.js # postinstall · ccsm:// + launcher.vbs
148
+ │ └── uninstall.js # preuninstall · cleanup + /api/shutdown
149
+ ├── lib/
150
+ │ ├── sessions.js # ~/.claude/sessions/*.json + tasklist PID check
151
+ │ ├── snapshot.js # save / load / rotate / restore
152
+ │ ├── workspace.js # workspace = folder under workDir
153
+ │ ├── launcher.js # spawn wt / pwsh / cmd
154
+ │ ├── focus.js # PowerShell + Win32 EnumWindows / SetForegroundWindow
155
+ │ ├── webTerminal.js # in-process PTY pool · node-pty + WebSocket bridge
156
+ │ ├── favorites.js · labels.js # pinned sessions / rename overrides
157
+ │ ├── jsonStore.js # shared keyed-JSON store factory
158
+ │ └── config.js # loadConfig / saveConfig
159
+ └── public/ # Preact + HTM + signals (no build step) — also pushed to GH Pages /v1/
160
+ ├── index.html # relative paths so it works at any host path
161
+ ├── manifest.webmanifest # PWA · relative start_url, WCO display override
162
+ ├── favicon.svg
163
+ ├── js/
164
+ │ ├── backend.js # httpBase() / wsBase() — same-origin local, cross-origin hosted
165
+ │ ├── main.js # boot · clock tick · heartbeat · is-app body class
166
+ │ ├── state.js # signals
167
+ │ ├── api.js # fetch wrapper + loaders
168
+ │ ├── streaming.js # NDJSON clone-progress stream
169
+ │ ├── actions.js # focus / resume / favorite / rename
170
+ │ ├── dialog.js · toast.js # ccsmConfirm / ccsmPrompt / setToast
171
+ │ ├── html.js · icons.js · util.js
172
+ │ ├── components/
173
+ │ │ ├── App.js · Sidebar.js · PageHead.js · Footer.js
174
+ │ │ ├── ServerStatus.js · Toast.js · Fab.js · OfflineBanner.js
175
+ │ │ ├── Card.js · Pagination.js · TitleCell.js
176
+ │ │ ├── SessionsTable.js · RecentTable.js · FavoritesTable.js
177
+ │ │ ├── RepoPicker.js · ReposEditor.js · WorkspacePicker.js
178
+ │ │ ├── WorkspacesGrid.js · SnapshotPanel.js · ProgressList.js
179
+ │ │ ├── TerminalView.js · NewSessionModal.js
180
+ │ │ └── DialogHost.js
181
+ │ └── pages/
182
+ │ ├── SessionsPage.js · LaunchPage.js
183
+ │ ├── TerminalsPage.js
184
+ │ ├── ConfigurePage.js · AboutPage.js
185
+ └── css/ # 13 focused stylesheets
186
+ ├── tokens.css · base.css · layout.css
187
+ ├── sidebar.css · cards.css · tables.css · forms.css
188
+ ├── widgets.css · feedback.css · modal.css
189
+ ├── terminals.css · wco.css · responsive.css
190
+
191
+ ~/.ccsm/ # or $CCSM_HOME
192
+ ├── config.json # source of truth
193
+ ├── snapshot.json # latest auto-snapshot
194
+ ├── snapshots/ # rotating history (default keep=30)
195
+ ├── favorites.json · labels.json # pinned / renamed sessions
196
+ ├── server.log # detached-server stdout/stderr
197
+ ├── .first-run-shown # marker so launcher only prints PWA hint once
198
+ └── browser-profile/ # Edge/Chrome --user-data-dir when browserMode=app
199
+
200
+ %LOCALAPPDATA%/ccsm/
201
+ └── launcher.vbs # silent ccsm:// dispatcher (written by postinstall)
202
+
203
+ HKCU\Software\Classes\ccsm # URL protocol registration
72
204
  ```
73
205
 
74
- The alternative ("one repo per workspace") was explicitly rejected don't refactor toward it without re-confirming.
206
+ On first run, if a legacy `<repo>/data/` directory exists and `~/.ccsm/`
207
+ is empty, `lib/config.js` copies the old data over (one-time,
208
+ idempotent).
209
+
210
+ ## Locked-in design decisions
211
+
212
+ **Workspace = folder holding multiple repo clones.** Each `ws-N` under
213
+ `workDir` contains a subdirectory per cloned repo. Claude launches at
214
+ the workspace root so all selected repos are sibling folders. "One repo
215
+ per workspace" was explicitly rejected.
216
+
217
+ **wt: one window per session, not stacked tabs.** Both
218
+ `/api/snapshot/restore` and `/api/sessions/new` open a fresh `wt`
219
+ window. "Stacked tabs via `-w 0 nt`" was explicitly rejected.
220
+
221
+ **"In use" detection.** A workspace is in use iff any live Claude
222
+ session's cwd is at-or-inside the workspace path (case-insensitive
223
+ Windows compare via `path.resolve().toLowerCase()`).
75
224
 
76
- **wt: one window per session, not stacked tabs.** Both `/api/snapshot/restore` and `/api/sessions/new` open a fresh `wt` window. The alternative ("`-w 0 nt` stacking in one window") was explicitly rejected — tabs become hard to track when restoring 8+ sessions.
225
+ **Workspace naming.** Auto-allocated names are `ws-1`, `ws-2`,
226
+ (lowest free integer). Hand-named folders under `workDir` are still
227
+ picked up.
77
228
 
78
- **"In use" detection.** A workspace is in use iff any live Claude session's cwd is at-or-inside the workspace path (case-insensitive Windows compare via `path.resolve().toLowerCase()`). New-session always tries to pick a free workspace before creating `ws-N+1`.
229
+ **Frontend trusts the backend's capability advertisement.**
230
+ `/api/capabilities` returns `{ webTerminal: true|false, ... }`. The
231
+ frontend uses ONLY features the backend says it has. Future breaking
232
+ changes ship a fresh `/v2/` frontend path; old `/v1/` still works
233
+ against backends that advertise the v1 contract.
79
234
 
80
- **Workspace naming.** Auto-allocated names are `ws-1`, `ws-2`, … (lowest free integer). Hand-named folders the user drops under `workDir` are still picked up — workspace name = literal folder name.
235
+ **One source of truth for cross-origin.** `public/js/backend.js`
236
+ exports `httpBase()` and `wsBase()`. Localhost → same-origin (empty
237
+ base). Anything else → `http://localhost:7777`. CORS on the backend
238
+ allows `https://bakapiano.github.io` only — never `*`.
81
239
 
82
240
  ## API surface
83
241
 
84
242
  | Method | Path | Purpose |
85
243
  |---|---|---|
86
244
  | GET | `/api/sessions` | live sessions sorted by `updatedAt` desc |
87
- | GET / PUT | `/api/config` | read / replace config (merged against defaults) |
245
+ | GET | `/api/sessions/recent` | recently-used sessions from jsonl mtimes · `?limit=&offset=` |
246
+ | POST | `/api/sessions/new` | body `{repos, workspace?, terminal: 'wt'\|'web'}` — NDJSON stream |
247
+ | POST | `/api/sessions/finder` | opens a wt with `claude` in `~/.ccsm` and `finderPrompt` |
248
+ | POST | `/api/sessions/:id/resume` | body `{cwd}` — `wt -d <cwd> claude --resume <id>` |
249
+ | POST | `/api/sessions/:id/focus` | match a wt window by title, fall back to PID-parent walk |
250
+ | GET | `/api/sessions/web` | list active web-terminal PTY sessions |
251
+ | DELETE | `/api/sessions/web/:id` | kill a web-terminal PTY |
252
+ | GET / PUT | `/api/config` | read / replace config |
88
253
  | GET / POST | `/api/snapshot` | latest snapshot / force-save now |
89
254
  | GET | `/api/snapshot/history` | rotated history filenames |
90
- | POST | `/api/snapshot/restore` | launch one wt window per session in latest snapshot (body `{file}` for historic) |
255
+ | POST | `/api/snapshot/restore` | launch one wt window per session in snapshot |
91
256
  | GET | `/api/workspaces` | workspaces under workDir with repo clone status + in-use flag |
92
- | POST | `/api/sessions/new` | body `{repos, workspace?, launch?}` — picks/creates ws, clones missing repos, launches wt |
93
- | POST | `/api/sessions/finder` | opens a wt with `claude` in `D:\ccsm` and the `finderPrompt` as opening message |
94
- | POST | `/api/sessions/:id/resume` | body `{cwd}` — launches `wt -d <cwd> claude --resume <id>` |
95
- | GET | `/api/sessions/recent` | recently-used sessions from `~/.claude/projects/*/*.jsonl` mtimes, excluding live ids · query `?limit=15&offset=0` for pagination · returns `{recent, total, limit, offset}` |
96
- | POST | `/api/sessions/:id/focus` | matches a wt window by title (cleaned of leading status glyphs) against the session's ai-title; falls back to PID-parent walk if no unique match |
97
- | GET | `/api/favorites` | array of pinned sessions sorted by `addedAt` desc |
98
- | POST | `/api/favorites/:id` | star a session; body `{cwd, title, gitBranch?, label?}` (snapshot of current row data so the favorite stays meaningful after the jsonl is gone) |
99
- | DELETE | `/api/favorites/:id` | unstar |
100
- | GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
101
- | GET | `/api/health` | sanity ping |
102
-
103
- `/api/sessions/new` streams **NDJSON** (one JSON object per line, `Content-Type: application/x-ndjson`). Event types: `workspace`, `clone-start`, `clone-progress` (phase/percent/current/total/detail), `clone-line` (raw git stderr line when not a progress line), `clone-end`, `launched`, `done`. The frontend reads it with `fetch().body.getReader()` + `TextDecoder` and updates per-repo progress bars live.
257
+ | GET | `/api/favorites` · POST/DELETE `/api/favorites/:id` | star / unstar |
258
+ | GET | `/api/labels` · PUT/DELETE `/api/labels/:id` | rename / clear override |
259
+ | GET | `/api/terminals` | enumerate built-in terminal kinds |
260
+ | GET | `/api/capabilities` | `{ webTerminal: bool, ... }` for frontend feature gating |
261
+ | GET | `/api/health` | sanity ping + version |
262
+ | POST | `/api/heartbeat` | called every 10s by the frontend; feeds lifecycle decisions |
263
+ | POST | `/api/spawn-browser` | open another Edge window into the running server |
264
+ | POST | `/api/shutdown` | gracefulShutdown — used by uninstall + auto-upgrade |
265
+ | WS | `/ws/terminal/:id` | xterm.js bridge to a PTY in the webTerminal pool |
266
+ | GET (dev) | `/api/dev/ping` · `/api/dev/reload` | hot-reload SSE (only when running from a checkout) |
267
+
268
+ `/api/sessions/new` streams **NDJSON** (one JSON object per line). Event
269
+ types: `workspace`, `clone-start`, `clone-progress` (phase/percent/
270
+ current/total/detail), `clone-line` (raw git stderr line when not a
271
+ progress line), `clone-end`, `launched`, `done`. The frontend reads it
272
+ with `fetch().body.getReader()` + `TextDecoder` and updates per-repo
273
+ progress bars live.
274
+
275
+ **WebSocket Origin check**: same allow-list as CORS. The upgrade handler
276
+ rejects any Origin not in `ALLOWED_ORIGINS` (plus localhost/127.0.0.1).
277
+ Browsers always send Origin on WS upgrades.
104
278
 
105
279
  ## Non-obvious gotchas
106
280
 
107
- **wt.exe `-d` flag, verified variants** (marker-file probes confirming `%CD%` inside the new tab):
281
+ **wt.exe `-d` flag** (marker-file probes confirming `%CD%` inside the new tab):
108
282
 
109
283
  | variant | result |
110
284
  |---|---|
@@ -116,79 +290,159 @@ The alternative ("one repo per workspace") was explicitly rejected — don't ref
116
290
  | `wt -d "D:\ccsm" <cmd>` (literal quotes) | ✗ wt doesn't open |
117
291
  | `wt new-tab -d D:\ccsm <cmd>` | ✗ wt doesn't open |
118
292
 
119
- ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends against a malformed `D:ccsm` (no separator) being interpreted as "current dir on drive D + ccsm", which would resolve to e.g. `D:\ccsm\ccsm`.
293
+ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends
294
+ against a malformed `D:ccsm` being interpreted as "current dir on drive
295
+ D + ccsm".
120
296
 
121
297
  **Don't test wt launching via `node -e "..."` inside `bash -c`.** Backslashes get eaten by shell quoting and the JS string ends up malformed. Write a `.js` file and `node file.js` instead.
122
298
 
123
- **focusBySession — title-based wt window matching.** Walking up the claude.exe PID parent chain and taking `MainWindowHandle` of the wt process *always* returns the same canonical window in modern multi-window single-process wt — clicking different sessions all focused the same window. `focusBySession` instead lists all visible wt windows (`EnumWindows` filtered by process name), strips the leading wt status glyph (`✳ `, `⠐ `, `⠠ ` …) and compares to the session's ai-title. Falls back to title-substring, then cwd-basename, then the old PID-parent walk when no unique match — at least the user sees *some* wt window even if it's the wrong tab. Caveat: for sessions sitting in an inactive tab of a multi-tab wt window, the window title shows the *active* tab's title — so we can't find them by title alone and the fallback is wrong-tab.
124
-
125
- **Focus helper (`lib/focus.js`).** One `powershell.exe -EncodedCommand <base64>` invocation per call, dispatched by `CCSM_FOCUS_MODE` env var into modes `list` (enumerate visible top-level windows owned by a process name), `focus-hwnd` (activate a specific HWND), `focus-pid` (walk parent chain to MainWindowHandle then activate). Encoded as UTF-16-LE-base64 — passing the C# block via stdin to `-Command -` silently produces no output, so we don't. Three gotchas baked in:
126
- 1. C# `out _` discard breaks PowerShell 5.1's bundled C# compiler — use a named `uint dummy`.
127
- 2. Windows blocks background processes from `SetForegroundWindow` — synthesize an Alt-key down/up via `keybd_event 0x12` first ("Alt-key trick") to qualify our process. Without it, `activated` returns false even though the window is found.
128
- 3. `ConvertTo-Json -AsArray` is PS 7+; on PS 5.1 build the JSON array manually as `'[' + ($items -join ',') + ']'` to avoid the single-element flattening.
129
-
130
- **HWND-diff for auto-focus, not PID-diff.** Modern Windows Terminal is multi-window single-process: one `WindowsTerminal.exe` PID owns 8+ top-level HWNDs. So `tasklist`-style PID-set-diff after launch returns empty even though a new window actually opened. We list visible top-level windows (filtered by owning-process name) via `EnumWindows`, snapshot the HWND set before launch, poll after, focus the new HWND. Works uniformly for wt and the per-process terminals (`powershell.exe` etc) where each launch is also a new process.
131
-
132
- **wt `-w new`.** wt's `windowingBehavior` setting in some user profiles folds new `wt …` invocations into the existing window as a tab, breaking "one window per session". Force-prepending `-w new` makes wt always create a new window (which is one of those many HWNDs above). Without `-w new`, both the auto-focus path and the design-decision-of-one-window-per-session silently break.
133
-
134
- **Auto-snapshot loop.** `setInterval` in `server.js` calls `saveSnapshot` every `snapshotIntervalMs`. The history dir grows until `snapshotHistoryKeep` is exceeded, then oldest are pruned.
135
-
136
- **Session listing.** `~/.claude/sessions/<pid>.json` is the source of truth. We cross-check the `pid` field against `tasklist /FI "IMAGENAME eq claude.exe"` so stale entries from crashed/exited claudes don't appear. `ai-title` is read by tailing the last 1 MB of the matching `.jsonl` and finding the last `"type":"ai-title"` line.
137
-
138
- **`projectSlugForCwd`.** The path-to-slug mapping is `cwd.replace(/[:\\]/g, '-')`, e.g. `D:\ccsm` `D--ccsm`, `C:\Users\foo` → `C--Users-foo`, `D:\` → `D--`.
299
+ **focusBySession — title-based wt window matching.** Walking up the
300
+ claude.exe PID parent chain and taking `MainWindowHandle` of the wt
301
+ process *always* returns the same canonical window in modern multi-
302
+ window single-process wt clicking different sessions all focused the
303
+ same window. `focusBySession` lists all visible wt windows
304
+ (`EnumWindows` filtered by process name), strips the leading wt status
305
+ glyph (`✳ `, `⠐ `, `⠠ ` …) and compares to the session's ai-title.
306
+ Falls back to title-substring, then cwd-basename, then the old PID-
307
+ parent walk when no unique match.
308
+
309
+ **Focus helper (`lib/focus.js`).** One `powershell.exe -EncodedCommand <base64>` invocation per call, dispatched by `CCSM_FOCUS_MODE` env var. Encoded as UTF-16-LE-base64. Three baked-in gotchas:
310
+ 1. C# `out _` discard breaks PowerShell 5.1's C# compiler use a named `uint dummy`.
311
+ 2. Windows blocks background processes from `SetForegroundWindow` — synthesize an Alt-key down/up via `keybd_event 0x12` first ("Alt-key trick").
312
+ 3. `ConvertTo-Json -AsArray` is PS 7+; on PS 5.1 build the JSON array manually.
313
+
314
+ **HWND-diff for auto-focus, not PID-diff.** Modern Windows Terminal is multi-window single-process: one `WindowsTerminal.exe` PID owns 8+ top-level HWNDs.
315
+
316
+ **wt `-w new`.** wt's `windowingBehavior` setting in some profiles folds new `wt …` invocations into the existing window as a tab. Force-prepending `-w new` makes wt always create a new window.
317
+
318
+ **Auto-snapshot loop.** `setInterval` in `server.js` calls
319
+ `saveSnapshot` every `snapshotIntervalMs`. The history dir grows until
320
+ `snapshotHistoryKeep` is exceeded, then oldest are pruned. AND
321
+ `gracefulShutdown` always saves one last snapshot before exit so a
322
+ restart can restore current state.
323
+
324
+ **Session listing.** `~/.claude/sessions/<pid>.json` is the source of
325
+ truth. We cross-check the `pid` field against
326
+ `tasklist /FI "IMAGENAME eq claude.exe"` so stale entries from crashed
327
+ claudes don't appear. `ai-title` is read by tailing the last 1 MB of
328
+ the matching `.jsonl` and finding the last `"type":"ai-title"` line.
329
+
330
+ **`projectSlugForCwd`.** `cwd.replace(/[:\\]/g, '-')`, e.g. `D:\ccsm` → `D--ccsm`.
331
+
332
+ **ccsm:// silent dispatch.** Direct registration of `ccsm.cmd` as the
333
+ protocol handler causes a brief console window flash (cmd hosts the
334
+ .cmd file). The wscript.exe + .vbs wrapper avoids it entirely — wscript
335
+ is a Windows-subsystem host (no console) and `Shell.Run(..., 0, False)`
336
+ launches the target hidden. The `.vbs` is generated at install time
337
+ with the correct ccsm.cmd path baked in.
338
+
339
+ **Edge --app handoff race.** When the user has an existing Edge profile
340
+ process running, `--app=URL --user-data-dir=DIR` against the same DIR
341
+ may cause the new msedge.exe to immediately exit after handing the URL
342
+ off to the existing process. Our child handle dies milliseconds after
343
+ spawn. The lifecycle hook ignores any browser-child exit inside the
344
+ first 5s for exactly this reason.
139
345
 
140
346
  ## Frontend design language
141
347
 
142
- The UI deliberately copies **claude.ai's** calm light aesthetic — warm cream surfaces, generous spacing, soft borders, single Claude-orange accent. Don't dark-mode-ify or chrome-ify.
348
+ The UI deliberately copies **claude.ai's** calm light aesthetic — warm
349
+ cream surfaces, generous spacing, soft borders, **no orange highlights**.
350
+ The brand orange `#b3614a` survives only in the brand mark / wordmark
351
+ dot. Every other "highlight" use (selection, focus rings, dirty
352
+ indicators, progress bars, page-actions banner) is ink/gray.
143
353
 
144
- **Palette** (CSS vars in `public/styles.css`):
354
+ **Palette** (CSS vars in `public/css/tokens.css`):
145
355
  - `--bg` `#faf9f5` warm cream page background
146
356
  - `--bg-elev` `#ffffff` card surfaces
147
- - `--sidebar-bg` `#f3f0e8` slightly darker cream for the rail
148
- - `--border` `#e8e3d5` hairlines
149
- - `--ink` `#1a1815` body text (warm near-black)
150
- - `--ink-mid` `#534e44` secondary
151
- - `--ink-muted` `#8a8475` meta
152
- - `--accent` `#c45f3f` Claude warm orange for primary actions, focus rings, active states ONLY
153
- - Status: green `#4a8a4a` idle · yellow `#c4892b` busy (pulsing) · red `#b73f3f` danger
357
+ - `--sidebar-bg` `#faf9f5` (same as `--bg`, single continuous surface)
358
+ - `--border` `#e8e3d5`
359
+ - `--ink` `#1a1815` body text (warm near-black, also used for terminal background)
360
+ - `--ink-mid` / `--ink-muted` / `--ink-faint`
361
+ - `--accent` `#b3614a` desaturated terracotta — brand only
362
+ - Status: green `#4a8a4a` idle · blue `#4a73a5` busy (pulsing) · red `#b73f3f` danger
363
+ - Favorite star: `#e3b341` (gold, the only intentional non-grayscale accent in the data area)
154
364
 
155
365
  **Type**:
156
- - Body / headings: **Geist** (Google Fonts, 300–700). No Fraunces / no italic display.
157
- - Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags, timestamps in meta.
366
+ - Body / headings: **Geist** (Google Fonts, 300–700).
367
+ - Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags.
158
368
  - Always `font-variant-numeric: tabular-nums` on numeric cells.
159
369
 
370
+ **Buttons**:
371
+ - `.action` (default) — white bg, ink-mid border, ink text.
372
+ - `.action.primary` — black ink bg, white text. The "do this" CTA.
373
+ - `.action.subtle` — transparent bg, light border.
374
+ - `.action.danger` — filled red bg + white text (e.g. Remove repo).
375
+
160
376
  **Layout**:
161
- - **Sidebar** (collapsible, ~232px ↔ ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`):
162
- - top: brand mark (orange rounded square) + `ccsm.` wordmark
163
- - mid: 3 nav items (Sessions / Launch / Configure) with stroke icons + label + optional badge
164
- - divider + utility items (Refresh, Ask Claude)
165
- - footer: collapse toggle (chevron flips on collapse via CSS rotate)
166
- - **Main column**: page header (title + subtitle + meta row of port/terminal/clock) content cards footer status line
167
- - Cards: `.card` (white, 10px radius, very soft `--shadow`), `.card-head` with title+meta, `.card-body` with optional `.card-body-flush` for tables.
168
- - Tables: wrapped in `.table-scroll` (`overflow-x: auto`, min-width 760px) so narrow viewports scroll horizontally instead of cramping.
377
+ - Sidebar (collapsible, ~232px ↔ ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`)
378
+ - brand mark + `CCSM.` wordmark
379
+ - 5 nav items: Sessions / Launch / Terminals / Configure / About (Terminals only shown when `capabilities.webTerminal === true`)
380
+ - footer: Collapse toggle (chevron flips on collapse)
381
+ - Page-head: title + subtitle on the left, server-status pill + Refresh button on the right
382
+ - Top-right control group uses fixed `min-height: 28px` and `border-radius: 999px` so server-status + Refresh align as a coherent control row
169
383
 
170
384
  **Animation**:
171
- - **Don't re-animate on refresh.** Rows have a one-shot staggered fade-in animation. `app.js` `markRendered(tableId)` adds `.no-anim` to the tbody after the first render via double `requestAnimationFrame`, so subsequent 5-second auto-refreshes don't restage every row (would strobe).
385
+ - Row staggered fade-in on first render. Preact's component identity prevents re-stage on auto-refresh (no `markRendered` machinery needed in v0.7+).
172
386
  - Panel switch: 0.35s `panel-in` fade-up.
173
- - Tab indicator: orange left bar on active sidebar item (`::before`).
174
- - Busy status mark: green-pulse via `box-shadow` keyframes.
175
-
176
- **Star (favorites) UI**:
177
- - Star button sits **inside the title cell**, right next to the title text (not in its own column). Outline-style by default at 55% opacity; row-hover bumps to 100%; favorited state fills with the accent color.
178
- - Click is delegated at the table level (`button[data-star]`). Toggle is **optimistic**: `state.favorites` updates and re-renders all 3 tables before the network call returns; failure shows a toast.
179
- - Backend snapshots `cwd / title / gitBranch` into `favorites.json` so the favorite is still meaningful after the source jsonl is gone.
180
-
181
- **No emoji in the UI** unless the user typed it (e.g. wt status glyphs in session titles). Use inline SVG icons everywhere (line stroke, 1.5–2px) so they take `currentColor` and live with the type weight.
182
-
183
- ## Lifecycle: server tied to browser window
184
-
185
- When the user launches via `npx @bakapiano/ccsm` from an interactive terminal (`process.stdout.isTTY === true`) AND `browserMode === 'app'`, the server keeps the spawned Edge/Chrome child handle and listens for its `exit` event. When the user closes the chromeless window, msedge.exe (running with its own `--user-data-dir=<DATA_DIR>/browser-profile` process group) exits, our hook fires `process.exit(0)`, and the terminal returns to a prompt. Headless / `nohup` launches don't get this hook (no TTY) and stay running.
387
+ - Busy status mark: blue pulse via `box-shadow` keyframes.
388
+
389
+ **No emoji in the UI** unless the user typed it (e.g. wt status glyphs
390
+ in session titles). Use inline SVG icons everywhere (line stroke, 1.5–
391
+ 2px) so they take `currentColor`.
392
+
393
+ **PWA + WCO**:
394
+ - `display_override: ["window-controls-overlay", "standalone"]` in
395
+ manifest. When installed and launched as PWA, the title bar's
396
+ middle is reclaimed; only OS controls float top-right.
397
+ - `public/css/wco.css` provides drag regions (`-webkit-app-region:
398
+ drag` on `.sidebar-brand`, `.page-head`, etc., unconditional — Chromium
399
+ ignores in plain tabs, honors in PWA / `--app=`). Interactive
400
+ elements opt out via the no-drag block.
401
+ - Padding gets shuffled (`body.is-app .main / .sidebar` lose top
402
+ padding, children compensate) so the very top of the window is
403
+ draggable instead of being `.main`'s dead padding zone.
404
+
405
+ ## Versioning
406
+
407
+ The hosted frontend lives at `/v1/`. Backend follows semver but treats
408
+ its API surface as additive:
409
+ - Patch: bug fixes, no API change.
410
+ - Minor: new endpoints, new fields on existing responses. Old frontend tolerates unknown extras.
411
+ - Major: breaking changes. New frontend at `/v2/`; old frontend at `/v1/` keeps working against backends that advertise the v1 contract via `/api/capabilities`.
412
+
413
+ `bin/ccsm.js` does auto-upgrade-restart: when the user runs `ccsm` and
414
+ the installed package version differs from a running backend, it POSTs
415
+ `/api/shutdown` to the old, waits for the port to free, then spawns a
416
+ fresh server. So `npm i -g @bakapiano/ccsm@latest && ccsm` is one
417
+ seamless step.
418
+
419
+ ## Cross-platform
420
+
421
+ Today: Windows-first.
422
+
423
+ Cross-platform-clean already:
424
+ - Frontend (pure web)
425
+ - `bin/ccsm.js` (pure node)
426
+ - `lib/webTerminal.js` (node-pty handles platform)
427
+ - `lib/snapshot.js`, `lib/config.js`, `lib/jsonStore.js` (fs only)
428
+ - `server.js` Express + ws
429
+
430
+ Windows-specific (need ports for Mac/Linux):
431
+ - `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`.
432
+ - `lib/focus.js` — PowerShell + Win32. Mac: `osascript`. Linux: `wmctrl`.
433
+ - `lib/launcher.js` — wt/pwsh/cmd. Mac: Terminal.app via osascript. Linux: gnome-terminal etc.
434
+ - `lib/sessions.js` — `tasklist`. Mac/Linux: `ps -eo pid,comm`.
435
+
436
+ Pattern for adding a platform: `switch (process.platform)` at each
437
+ entry point in those files. Each platform branch is roughly 50-100
438
+ lines.
186
439
 
187
440
  ## Extending
188
441
 
189
442
  When adding features, the natural extension points:
190
- - New REST routes: `server.js` (keep them under `/api/*`, use `asyncH` wrapper).
191
- - Frontend section: add a `<section class="card">` in `public/index.html` and a render function in `public/app.js`. Use `markRendered(tableId)` after the first render to suppress refresh strobing.
192
- - Persistent user data: drop a JSON file under `~/.ccsm/` (like `favorites.json`) and wrap with a small lib module — config.js / favorites.js pattern.
193
- - Workspace lifecycle (delete, rename): `lib/workspace.js`.
194
- - Different launch modes (e.g., stacked tabs): `lib/launcher.js` — but check first whether the "one window per session" decision still holds.
443
+ - **New REST routes**: `server.js` (keep under `/api/*`, use the `asyncH` wrapper, decide if it needs CORS by being in the allow-list).
444
+ - **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`.
445
+ - **Persistent user data**: drop a JSON file under `~/.ccsm/` and use `lib/jsonStore.js`'s factory.
446
+ - **Workspace lifecycle** (delete, rename): `lib/workspace.js`.
447
+ - **Different launch modes**: `lib/launcher.js` — but check first whether the "one window per session" decision still holds.
448
+ - **A capability**: advertise via `/api/capabilities`. Frontend gates UI on `caps.<feature>`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -104,8 +104,29 @@ try {
104
104
  registerProtocol(vbsPath);
105
105
  log(`launcher · ${vbsPath}`);
106
106
  log(`ccsm:// protocol registered (silent · via wscript.exe)`);
107
- log('open https://bakapiano.github.io/cssm/v1/ and click "Start ccsm" on the offline banner to launch the backend.');
108
107
  } catch (e) {
109
108
  warn(`failed · ${e.message}`);
110
109
  warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
111
110
  }
111
+
112
+ // Auto-launch ccsm after install so the user lands directly in the app
113
+ // without needing a second command. Detached + windowsHide so the npm
114
+ // install command returns immediately. Skip if CCSM_NO_AUTOLAUNCH=1 is
115
+ // set (CI, headless setups).
116
+ if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
117
+ try {
118
+ const { spawn } = require('node:child_process');
119
+ const child = spawn(ccsmCmd, [], {
120
+ detached: true,
121
+ stdio: 'ignore',
122
+ windowsHide: true,
123
+ shell: false,
124
+ });
125
+ child.unref();
126
+ log('launching ccsm now · check for the chromeless window');
127
+ log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
128
+ } catch (e) {
129
+ warn(`auto-launch failed · ${e.message}`);
130
+ warn('run `ccsm` manually to start.');
131
+ }
132
+ }