@bakapiano/ccsm 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CLAUDE.md +222 -195
  2. package/README.md +77 -79
  3. package/lib/cliSessionWatcher.js +249 -0
  4. package/lib/config.js +101 -24
  5. package/lib/folders.js +96 -0
  6. package/lib/localCliSessions.js +177 -0
  7. package/lib/persistedSessions.js +134 -0
  8. package/lib/webTerminal.js +31 -18
  9. package/lib/workspace.js +26 -4
  10. package/package.json +1 -1
  11. package/public/assets/claude-color.svg +1 -0
  12. package/public/assets/codex-color.svg +1 -0
  13. package/public/assets/copilot-color.svg +1 -0
  14. package/public/css/base.css +22 -5
  15. package/public/css/cards.css +37 -3
  16. package/public/css/feedback.css +127 -43
  17. package/public/css/forms.css +97 -25
  18. package/public/css/layout.css +74 -26
  19. package/public/css/modal.css +40 -26
  20. package/public/css/responsive.css +2 -2
  21. package/public/css/sidebar.css +424 -25
  22. package/public/css/terminals.css +138 -0
  23. package/public/css/tokens.css +28 -12
  24. package/public/css/wco.css +38 -39
  25. package/public/css/widgets.css +1177 -6
  26. package/public/index.html +35 -2
  27. package/public/js/api.js +194 -37
  28. package/public/js/components/AdoptModal.js +171 -0
  29. package/public/js/components/App.js +1 -11
  30. package/public/js/components/DirectoryPicker.js +203 -0
  31. package/public/js/components/EntityFormModal.js +105 -0
  32. package/public/js/components/Modal.js +51 -0
  33. package/public/js/components/OfflineBanner.js +29 -23
  34. package/public/js/components/PageTitleBar.js +13 -0
  35. package/public/js/components/Picker.js +179 -0
  36. package/public/js/components/Popover.js +55 -0
  37. package/public/js/components/Sidebar.js +219 -32
  38. package/public/js/components/TerminalView.js +27 -3
  39. package/public/js/components/useDragSort.js +67 -0
  40. package/public/js/dialog.js +10 -2
  41. package/public/js/icons.js +66 -3
  42. package/public/js/main.js +54 -3
  43. package/public/js/pages/AboutPage.js +80 -0
  44. package/public/js/pages/ConfigurePage.js +429 -207
  45. package/public/js/pages/LaunchPage.js +326 -86
  46. package/public/js/pages/SessionsPage.js +91 -41
  47. package/public/js/state.js +102 -73
  48. package/public/manifest.webmanifest +2 -2
  49. package/scripts/install.js +7 -2
  50. package/server.js +755 -441
  51. package/lib/favorites.js +0 -51
  52. package/lib/focus.js +0 -369
  53. package/lib/labels.js +0 -29
  54. package/lib/launcher.js +0 -219
  55. package/lib/sessions.js +0 -272
  56. package/lib/snapshot.js +0 -141
  57. package/public/js/actions.js +0 -107
  58. package/public/js/components/Fab.js +0 -11
  59. package/public/js/components/FavoritesTable.js +0 -81
  60. package/public/js/components/Footer.js +0 -12
  61. package/public/js/components/NewSessionModal.js +0 -153
  62. package/public/js/components/PageHead.js +0 -33
  63. package/public/js/components/Pagination.js +0 -27
  64. package/public/js/components/RecentTable.js +0 -68
  65. package/public/js/components/SessionsTable.js +0 -71
  66. package/public/js/components/SnapshotPanel.js +0 -77
  67. package/public/js/components/TitleCell.js +0 -40
  68. package/public/js/components/WorkspacesGrid.js +0 -41
  69. package/public/js/pages/TerminalsPage.js +0 -74
package/README.md CHANGED
@@ -1,22 +1,27 @@
1
1
  # ccsm — Claude Code Session Manager
2
2
 
3
- A single pane over every live Claude Code session on your machine.
4
- Hosted web UI + tiny local Node daemon. Windows-first; cross-platform
5
- in progress.
3
+ A single pane over every Claude / Codex / Copilot CLI session on your
4
+ machine. Each session runs inside the page (xterm.js + a PTY pool in
5
+ the local backend), gets recorded, and re-attaches to the exact
6
+ upstream conversation when you click it again.
6
7
 
7
- [![open](https://img.shields.io/badge/open-bakapiano.github.io%2Fccsm%2Fv1-1a1815?style=flat-square)](https://bakapiano.github.io/ccsm/v1/)
8
+ [![open](https://img.shields.io/badge/open-bakapiano.github.io%2Fccsm-1a1815?style=flat-square)](https://bakapiano.github.io/ccsm/)
8
9
 
9
10
  ```
10
11
  ┌── browser ─────────────────────────┐
11
- │ https://bakapiano.github.io/ccsm/v1/ static frontend
12
+ │ https://bakapiano.github.io/ccsm/ version router
13
+ │ ↓
14
+ │ /ccsm/X.Y.Z/ ← per-version frontend (pinned to your backend)
12
15
  └────────────┬───────────────────────┘
13
16
  │ fetch /api/* (CORS)
14
17
  │ ws://localhost:7777/ws/*
15
18
 
16
19
  ┌── local backend ───────────────────┐
17
20
  │ ccsm (npm bin) │
18
- │ ├── /api/sessions /api/snapshot
19
- │ ├── /api/sessions/new (NDJSON)
21
+ │ ├── /api/sessions /api/sessions/new
22
+ │ ├── /api/sessions/:id/resume
23
+ │ ├── /api/sessions/adopt │
24
+ │ ├── /api/version /api/upgrade │
20
25
  │ ├── /ws/terminal/:id (PTY) │
21
26
  │ └── /api/health /api/heartbeat │
22
27
  └────────────────────────────────────┘
@@ -24,12 +29,23 @@ in progress.
24
29
 
25
30
  ## What it does
26
31
 
27
- - **Lists every live Claude Code session** title, cwd, age, PID, status. Click **Focus** to raise the wt window that's hosting it (`EnumWindows` + `SetForegroundWindow`, Alt-key trick to defeat the foreground-lock).
28
- - **Snapshot + Restore** every 60s the full session set is captured to `~/.ccsm/snapshot.json`. One click restores them: one fresh wt window per session, `cd` + `claude --resume`.
29
- - **New session** picks an unused workspace under your work-dir, clones repos with live `git clone --progress` streamed to per-repo progress bars, opens a fresh `claude` in either a wt window or an in-page xterm.js terminal.
30
- - **Web terminal** `node-pty` PTY + xterm.js. Runs claude inside the page instead of wt. Optional, install-failure-tolerant.
31
- - **Favorites / labels / pagination** pin sessions, rename them, page through history.
32
- - **Ask Claude to find a session** opens a claude session pre-pointed at your ccsm data dir so you can grep past conversations.
32
+ - **Runs every CLI session in the page.** `claude`, `codex`, `copilot`
33
+ or any custom command, in an xterm.js panel. Switch sessions in the
34
+ sidebar; the PTY keeps running in the backend.
35
+ - **`--resume <uuid>` precision.** ccsm watches the upstream CLI's
36
+ transcript dir after spawn and captures its session UUID. Click a
37
+ stopped session later re-spawns with `--resume <uuid>` (or
38
+ whatever `resumeIdArgs` template you set per-CLI) so the exact
39
+ conversation comes back.
40
+ - **Import existing sessions.** Scans `~/.claude` / `~/.codex` /
41
+ `~/.copilot` and lets you adopt any session ccsm didn't start.
42
+ - **Workspaces + clones.** "New session" picks an unused workspace
43
+ under your work-dir, clones selected repos with live `git clone
44
+ --progress` streamed to per-repo progress bars, opens a fresh CLI
45
+ there. Or pick any existing folder via the file browser.
46
+ - **Folders.** Drag sessions into named folders for organisation.
47
+ - **In-app upgrade.** About page checks npm for newer versions of
48
+ ccsm and offers a one-click upgrade button. Backend self-restarts.
33
49
 
34
50
  ## Install
35
51
 
@@ -39,11 +55,11 @@ npm i -g @bakapiano/ccsm
39
55
 
40
56
  This:
41
57
  - puts `ccsm` on your PATH
42
- - registers a `ccsm://` URL protocol so the hosted frontend can wake the
43
- backend with one click
58
+ - registers a `ccsm://` URL protocol so the hosted frontend can wake
59
+ the backend with one click
44
60
 
45
- `npx @bakapiano/ccsm` works too for a one-shot trial — the protocol still
46
- gets registered.
61
+ `npx @bakapiano/ccsm` works too for a one-shot trial — the protocol
62
+ still gets registered.
47
63
 
48
64
  ## Use
49
65
 
@@ -51,11 +67,13 @@ gets registered.
51
67
  ccsm # starts the backend, opens the frontend
52
68
  ```
53
69
 
54
- Or just visit **https://bakapiano.github.io/ccsm/v1/** in any browser.
55
- If the backend isn't running, you'll see a "Backend not running" banner
56
- with a **Start ccsm** button — click it, Windows asks once whether to
57
- open the `ccsm://` handler (check "Always allow"), and the backend
58
- spawns silently behind the page. The page auto-reconnects in 1-2s.
70
+ Or just visit **https://bakapiano.github.io/ccsm/** in any browser.
71
+ If the backend isn't running, the router shows a "Backend not running"
72
+ banner with a **Start ccsm** button — click it, Windows asks once
73
+ whether to open the `ccsm://` handler (check "Always allow"), and the
74
+ backend spawns silently behind the page. The router auto-reconnects in
75
+ 1-2s and redirects to the frontend matching your installed backend
76
+ version.
59
77
 
60
78
  ### Install as PWA
61
79
 
@@ -73,15 +91,10 @@ terminal needed.
73
91
  |---|---|
74
92
  | Port | `7777` (auto-bumps if taken) |
75
93
  | Work dir | `~/ccsm-workspaces` (each subdirectory holds one or more repo clones) |
76
- | Terminal | `wt` (Windows Terminal). Also `powershell` / `pwsh` / `cmd` / `web` (in-page xterm.js). |
77
- | Claude command | `claude` any alias / function / exe. Wrapped in pwsh when terminal is `wt` so PowerShell aliases like `cc` resolve. |
78
- | Snapshot interval | 60s; last 30 kept under `~/.ccsm/snapshots/` |
79
- | Auto-focus | on (HWND-diff across the terminal process — handles modern wt's multi-window single-process layout) |
80
- | Repos | none by default — add through the **Configure** tab |
94
+ | Built-in CLIs | `claude`, `codex`, `copilot` add your own via the **Configure** tab |
95
+ | Data dir | `~/.ccsm/` (override with `CCSM_HOME=<path>`) survives upgrades and npx cache wipes |
81
96
 
82
- All of the above are editable through the **Configure** tab. State lives
83
- at `~/.ccsm/` (override with `CCSM_HOME=<path>`). Survives upgrades and
84
- npx cache wipes.
97
+ All of the above are editable through the **Configure** tab.
85
98
 
86
99
  ## Layout
87
100
 
@@ -93,37 +106,30 @@ ccsm/
93
106
  │ ├── install.js # postinstall · registers ccsm:// (Windows)
94
107
  │ └── uninstall.js # preuninstall · cleanup
95
108
  ├── lib/
96
- │ ├── sessions.js # ~/.claude/sessions/*.json + live PID check via tasklist
97
- │ ├── snapshot.js # save / load / rotate / restore
98
- │ ├── workspace.js # workspace = subfolder under workDir
99
- │ ├── launcher.js # spawn wt / pwsh / cmd
100
- │ ├── focus.js # PowerShell + Win32 EnumWindows / SetForegroundWindow
101
- │ ├── webTerminal.js # in-process PTY pool · node-pty + WebSocket bridge
102
- │ ├── config.js / favorites.js / labels.js / jsonStore.js
103
- └── public/ # Preact + HTM + signals (no build step) — also pushed to GH Pages
104
- ├── js/
105
- │ ├── backend.js # httpBase() / wsBase() — same-origin local, cross-origin GH Pages
106
- │ ├── main.js · state.js · api.js · streaming.js · actions.js · dialog.js · toast.js · util.js · icons.js
107
- │ ├── components/ # Sidebar, PageHead, Card, SessionsTable, RecentTable, FavoritesTable, TerminalView, NewSessionModal, OfflineBanner …
108
- │ └── pages/ # SessionsPage, LaunchPage, TerminalsPage, ConfigurePage, AboutPage
109
- └── css/ # 12 focused stylesheets (tokens, base, layout, sidebar, cards, tables, forms, widgets, feedback, modal, terminals, wco, responsive)
109
+ │ ├── persistedSessions.js # ~/.ccsm/sessions.json the source of truth
110
+ │ ├── folders.js # sidebar tree
111
+ │ ├── localCliSessions.js # scan ~/.claude · ~/.codex · ~/.copilot
112
+ │ ├── cliSessionWatcher.js # capture upstream session UUID after spawn
113
+ │ ├── workspace.js # ws-N allocation + repo clones
114
+ │ ├── webTerminal.js # node-pty pool · WebSocket bridge
115
+ │ ├── jsonStore.js · config.js
116
+ ├── pages-root/ # GH Pages / (version router)
117
+ └── public/ # → GH Pages /<pkg.version>/ (per-version frontend)
110
118
 
111
119
  ~/.ccsm/ # or $CCSM_HOME
112
- ├── config.json # source of truth
113
- ├── snapshot.json # latest auto-snapshot
114
- ├── snapshots/ # rotating history
115
- ├── favorites.json · labels.json
116
- ├── server.log # detached-server stdout/stderr
117
- └── .first-run-shown # marker so we only print the PWA-install hint once
120
+ ├── config.json
121
+ ├── sessions.json # persisted sessions
122
+ ├── folders.json
123
+ ├── server.log
124
+ └── browser-profile/ # Edge/Chrome --user-data-dir
118
125
  ```
119
126
 
120
127
  ## How "wake on click" works
121
128
 
122
- The hosted frontend (https://bakapiano.github.io/ccsm/v1/) lives entirely
123
- in the browser sandbox — it cannot spawn processes. So when the backend
124
- is down, the OfflineBanner's **Start ccsm** is a plain
125
- `<a href="ccsm://start">`. The OS hands that off to a per-user URL
126
- protocol handler we registered at install time:
129
+ The hosted frontend lives entirely in the browser sandbox — it cannot
130
+ spawn processes. So when the backend is down, the OfflineBanner's
131
+ **Start ccsm** is a plain `<a href="ccsm://start">`. The OS hands that
132
+ off to a per-user URL protocol handler registered at install time:
127
133
 
128
134
  ```
129
135
  HKCU\Software\Classes\ccsm\shell\open\command
@@ -131,8 +137,8 @@ HKCU\Software\Classes\ccsm\shell\open\command
131
137
  ```
132
138
 
133
139
  The `.vbs` calls `ccsm.cmd "ccsm://start"` with `WindowStyle = 0`. That
134
- gets to `bin/ccsm.js`, which parses the protocol URL, spawns `server.js`
135
- detached, and exits. Zero windows ever flash.
140
+ gets to `bin/ccsm.js`, which parses the protocol URL, spawns
141
+ `server.js` detached, and exits. Zero windows ever flash.
136
142
 
137
143
  First click triggers a one-time Windows dialog ("Open ccsm.cmd?"). Tick
138
144
  **Always allow** and future clicks are silent.
@@ -144,17 +150,16 @@ First click triggers a one-time Windows dialog ("Open ccsm.cmd?"). Tick
144
150
  | The auto-opened browser window closes | wait 12s · if any other client heartbeats during that window, stay alive; otherwise gracefulShutdown |
145
151
  | No heartbeat for 90s | gracefulShutdown |
146
152
  | `POST /api/shutdown` | gracefulShutdown |
153
+ | `POST /api/upgrade` after install | self-respawn + gracefulShutdown |
147
154
  | SIGINT / SIGTERM | gracefulShutdown |
148
155
 
149
- Every gracefulShutdown saves a final snapshot before exit.
150
-
151
156
  ## Dev
152
157
 
153
158
  ```bash
154
159
  git clone https://github.com/bakapiano/ccsm
155
- cd cssm
160
+ cd ccsm
156
161
  npm install
157
- node server.js
162
+ CCSM_NO_BROWSER=1 CCSM_KEEP_ALIVE=1 node server.js
158
163
  # opens http://localhost:7777 with hot-reload (public/ is served locally
159
164
  # and SSE pushes a reload event on every file save)
160
165
  ```
@@ -163,30 +168,23 @@ Dev mode is detected via `__dirname.includes('node_modules')` — when
163
168
  running from a checkout, the backend also serves `public/`. In an
164
169
  npm-installed copy it's API-only, and you use the hosted frontend.
165
170
 
166
- The frontend can also be loaded from GH Pages even in dev — it'll just
167
- talk to the same `localhost:7777` backend. Useful for testing the
168
- cross-origin path.
169
-
170
171
  ## Versioning (frontend ↔ backend)
171
172
 
172
- The hosted frontend lives at a versioned path (`/v1/`). Future breaking
173
- API changes ship a fresh `/v2/` while `/v1/` keeps serving. Each frontend
174
- build feature-detects via `/api/capabilities`, so a slightly older
175
- backend still works as long as it advertises the needed feature.
176
-
177
- ```
178
- https://bakapiano.github.io/ccsm/v1/ ← current
179
- https://bakapiano.github.io/ccsm/v2/ ← future, when /api breaking-changes
180
- ```
173
+ The hosted root (`/ccsm/`) is a tiny static **version router**: it
174
+ probes `localhost:7777/api/health`, then redirects you to
175
+ `/ccsm/<backend.version>/`. Each release publishes a fresh
176
+ per-version subdir; old ones stay forever. No semver-compat logic a
177
+ frontend is always 1:1 with the backend it was built against.
181
178
 
182
- Installed PWAs are pinned to whichever path they were installed from.
179
+ If your backend gets upgraded under a still-loaded page, the
180
+ per-version frontend detects the mismatch on its next probe and
181
+ bounces you back through the router automatically.
183
182
 
184
183
  ## Status
185
184
 
186
- - Backend: Windows-first. macOS / Linux backend ports planned (focus
187
- management, terminal spawning, and the protocol-handler registration
188
- are the only platform-specific pieces).
185
+ - Backend: Windows-first. macOS / Linux backend ports planned (URL
186
+ protocol registration is the only platform-specific install piece).
189
187
  - Frontend: cross-platform (pure web).
190
188
 
191
189
  See [CLAUDE.md](CLAUDE.md) for design decisions and the non-obvious
192
- gotchas baked into the launcher / focus / snapshot code.
190
+ gotchas baked into the launcher / session-watcher / lifecycle code.
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+
3
+ // Captures the upstream CLI's session id (claude / codex / copilot) so
4
+ // ccsm can later spawn `<cli> --resume <uuid>` and reattach to the same
5
+ // conversation precisely.
6
+ //
7
+ // Approach (poll-based, deliberately):
8
+ // - fs.watch is unreliable on Windows for in-place content writes.
9
+ // - CLIs reuse existing transcripts in the same cwd when they can —
10
+ // there's no "new file appears" signal to wait on.
11
+ // - Instead we poll the per-CLI transcript dir every POLL_MS, find
12
+ // candidates whose mtime > spawnAt, read each one's cwd field, and
13
+ // if exactly one matches our spawn cwd that's the session id.
14
+ // - Window expires after WINDOW_MS — CLIs only persist after the first
15
+ // user message, so this needs to be generous.
16
+ //
17
+ // Per-CLI profile shape:
18
+ // dirFor(cwd) → directory to poll
19
+ // entryType → 'file' (claude/codex) or 'dir' (copilot —
20
+ // each child dir = one session)
21
+ // recursive → for 'file' mode only; walk subdirs too
22
+ // filePattern → regex an entry's basename must match
23
+ // parseId(basename) → extract the upstream session id from name
24
+ // (returns null to skip)
25
+ // readCwd(entryPath) → async; returns the cwd recorded inside the
26
+ // session, or null if not yet readable.
27
+
28
+ const fs = require('node:fs');
29
+ const fsp = require('node:fs/promises');
30
+ const path = require('node:path');
31
+ const os = require('node:os');
32
+ const readline = require('node:readline');
33
+
34
+ const POLL_MS = 1_500;
35
+ const WINDOW_MS = 5 * 60_000;
36
+
37
+ const profiles = {
38
+ claude: {
39
+ dirFor: (cwd) => path.join(os.homedir(), '.claude', 'projects', claudeSlug(cwd)),
40
+ entryType: 'file',
41
+ filePattern: /\.jsonl$/i,
42
+ parseId: (filename) => filename.replace(/\.jsonl$/i, ''),
43
+ readCwd: (filepath) => firstJsonField(filepath, 'cwd', 12),
44
+ },
45
+ codex: {
46
+ dirFor: () => path.join(os.homedir(), '.codex', 'sessions'),
47
+ entryType: 'file',
48
+ recursive: true,
49
+ filePattern: /\.jsonl$/i,
50
+ parseId: (filename) => {
51
+ const m = filename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
52
+ return m ? m[1] : null;
53
+ },
54
+ readCwd: (filepath) => firstJsonField(filepath, 'cwd', 12),
55
+ },
56
+ copilot: {
57
+ // ~/.copilot/session-state/<uuid>/ with workspace.yaml + events.jsonl
58
+ // Each session is a directory, not a single file.
59
+ dirFor: () => path.join(os.homedir(), '.copilot', 'session-state'),
60
+ entryType: 'dir',
61
+ // Subdir name is the uuid; tolerate any non-empty name (copilot uses
62
+ // standard uuid4 but we'd rather not be strict).
63
+ parseId: (dirname) => /^[0-9a-f-]+$/i.test(dirname) ? dirname : null,
64
+ readCwd: async (dirpath) => {
65
+ // workspace.yaml has plain `cwd: <path>` on its own line — quick
66
+ // regex parse, no YAML dep.
67
+ const yaml = path.join(dirpath, 'workspace.yaml');
68
+ try {
69
+ const txt = await fsp.readFile(yaml, 'utf8');
70
+ const m = txt.match(/^\s*cwd\s*:\s*(.+?)\s*$/m);
71
+ return m ? m[1].trim() : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ },
76
+ },
77
+ };
78
+
79
+ function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDOW_MS }) {
80
+ const profile = profiles[cliType];
81
+ if (!profile) return () => {};
82
+ const dir = profile.dirFor(cwd);
83
+ const spawnAt = Date.now();
84
+ console.log(`[cliSessionWatcher] start ${cliType} dir=${dir} cwd=${cwd}`);
85
+
86
+ let stopped = false;
87
+ let captured = false;
88
+ let pollTimer = null;
89
+ let expireTimer = null;
90
+ // Track entries we've already proven aren't ours so we don't re-read them.
91
+ // Only used for "wrong cwd recorded inside the file" — that's stable.
92
+ // We do NOT cache mtime-based rejections: a stale file can be re-touched
93
+ // by the CLI later (claude appends to existing transcripts) and we want
94
+ // to re-evaluate it then.
95
+ const rejected = new Set();
96
+
97
+ const cleanup = () => {
98
+ if (stopped) return;
99
+ stopped = true;
100
+ if (pollTimer) clearTimeout(pollTimer);
101
+ if (expireTimer) clearTimeout(expireTimer);
102
+ };
103
+
104
+ const finish = (sessionId) => {
105
+ if (stopped) return;
106
+ captured = true;
107
+ cleanup();
108
+ console.log(`[cliSessionWatcher] captured ${cliType} ${sessionId}`);
109
+ try { onCapture?.(sessionId); } catch (e) { console.error('[cliSessionWatcher] onCapture:', e); }
110
+ };
111
+
112
+ const onExpire = () => {
113
+ if (stopped || captured) return;
114
+ cleanup();
115
+ console.warn(`[cliSessionWatcher] timeout ${cliType} (no transcript in ${Math.round(windowMs / 1000)}s) cwd=${cwd}`);
116
+ try { onTimeout?.(); } catch (e) { console.error('[cliSessionWatcher] onTimeout:', e); }
117
+ };
118
+
119
+ const poll = async () => {
120
+ if (stopped) return;
121
+ try {
122
+ const entries = await listEntries(dir, profile);
123
+ console.log(`[cliSessionWatcher] poll: ${entries.length} entries, rejected=${rejected.size}`);
124
+ const candidates = [];
125
+ for (const entryPath of entries) {
126
+ const base = path.basename(entryPath);
127
+ if (rejected.has(entryPath)) continue;
128
+ if (profile.filePattern && !profile.filePattern.test(base)) continue;
129
+ const id = profile.parseId(base);
130
+ if (!id) continue;
131
+ let st;
132
+ try { st = await fsp.stat(entryPath); } catch { continue; }
133
+ // Mtime gate is re-evaluated every poll: don't memoise it. If the
134
+ // CLI later re-touches an old transcript (claude appends to the
135
+ // existing one for the cwd), this poll will pick it up.
136
+ if (st.mtimeMs < spawnAt - 2000) continue;
137
+ candidates.push({ entryPath, id, mtime: st.mtimeMs });
138
+ }
139
+ const matched = [];
140
+ for (const c of candidates) {
141
+ const cwdFromEntry = await profile.readCwd(c.entryPath);
142
+ if (cwdFromEntry == null) continue; // not enough data yet
143
+ if (!samePath(cwdFromEntry, cwd)) { rejected.add(c.entryPath); continue; }
144
+ matched.push(c);
145
+ }
146
+ if (matched.length === 1) {
147
+ finish(matched[0].id);
148
+ return;
149
+ }
150
+ if (matched.length > 1) {
151
+ console.warn(`[cliSessionWatcher] ambiguous: ${matched.length} candidates for ${cwd} — skipping capture`);
152
+ cleanup();
153
+ return;
154
+ }
155
+ } catch (e) {
156
+ console.error('[cliSessionWatcher] poll:', e.message);
157
+ }
158
+ if (!stopped) pollTimer = setTimeout(poll, POLL_MS);
159
+ };
160
+
161
+ (async () => {
162
+ try { await fsp.mkdir(dir, { recursive: true }); } catch {}
163
+ if (stopped) return;
164
+ expireTimer = setTimeout(onExpire, windowMs);
165
+ poll();
166
+ })();
167
+
168
+ return cleanup;
169
+ }
170
+
171
+ module.exports = { captureSessionId };
172
+
173
+ // ── helpers ─────────────────────────────────────────────────────────
174
+
175
+ async function listEntries(root, profile) {
176
+ if (profile.entryType === 'dir') {
177
+ let names;
178
+ try { names = await fsp.readdir(root, { withFileTypes: true }); }
179
+ catch { return []; }
180
+ return names.filter((e) => e.isDirectory()).map((e) => path.join(root, e.name));
181
+ }
182
+ // file mode
183
+ if (profile.recursive) return listAllFiles(root);
184
+ try {
185
+ const names = await fsp.readdir(root);
186
+ return names.map((n) => path.join(root, n));
187
+ } catch {
188
+ return [];
189
+ }
190
+ }
191
+
192
+ function claudeSlug(cwd) {
193
+ return cwd.replace(/[:\\\/]/g, '-');
194
+ }
195
+
196
+ function samePath(a, b) {
197
+ if (!a || !b) return false;
198
+ const norm = (p) => path.resolve(p).replace(/[\\\/]+$/, '').toLowerCase();
199
+ return norm(a) === norm(b);
200
+ }
201
+
202
+ async function firstJsonField(filepath, field, maxLines) {
203
+ return new Promise((resolve) => {
204
+ let stream;
205
+ try { stream = fs.createReadStream(filepath, { encoding: 'utf8' }); }
206
+ catch { resolve(null); return; }
207
+ const rl = readline.createInterface({ input: stream });
208
+ let count = 0;
209
+ // rl.close() synchronously emits 'close' on some platforms, which would
210
+ // re-enter done(null) and clobber the value resolve. Guard against that.
211
+ let settled = false;
212
+ const done = (v) => {
213
+ if (settled) return;
214
+ settled = true;
215
+ try { rl.close(); } catch {}
216
+ try { stream.destroy(); } catch {}
217
+ resolve(v);
218
+ };
219
+ rl.on('line', (line) => {
220
+ count++;
221
+ try {
222
+ const obj = JSON.parse(line);
223
+ if (obj && Object.prototype.hasOwnProperty.call(obj, field)) {
224
+ done(obj[field]);
225
+ return;
226
+ }
227
+ } catch {}
228
+ if (count >= maxLines) done(null);
229
+ });
230
+ rl.on('close', () => done(null));
231
+ rl.on('error', () => done(null));
232
+ });
233
+ }
234
+
235
+ async function listAllFiles(root) {
236
+ const out = [];
237
+ const walk = async (dir) => {
238
+ let entries;
239
+ try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
240
+ catch { return; }
241
+ for (const e of entries) {
242
+ const p = path.join(dir, e.name);
243
+ if (e.isDirectory()) await walk(p);
244
+ else out.push(p);
245
+ }
246
+ };
247
+ await walk(root);
248
+ return out;
249
+ }
package/lib/config.js CHANGED
@@ -13,32 +13,59 @@ const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
13
13
 
14
14
  const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
15
15
 
16
+ // v1.0 — wt / system-terminal launch path removed. Sessions are always
17
+ // in-page web terminals managed by ccsm. CLI is pluggable: configure one
18
+ // or more entries under `clis` (claude, codex, custom wrappers), pick a
19
+ // default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
20
+ // `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
21
+ // `snapshot*`) are silently dropped on load.
22
+ const DEFAULT_CLIS = [
23
+ {
24
+ id: 'claude',
25
+ name: 'Claude Code',
26
+ command: 'claude',
27
+ args: [],
28
+ resumeArgs: ['--continue'],
29
+ resumeIdArgs: ['--resume', '<id>'],
30
+ shell: 'direct',
31
+ type: 'claude',
32
+ builtin: true,
33
+ },
34
+ {
35
+ id: 'codex',
36
+ name: 'OpenAI Codex',
37
+ command: 'codex',
38
+ args: [],
39
+ resumeArgs: ['resume', '--last'],
40
+ resumeIdArgs: ['resume', '<id>'],
41
+ shell: 'direct',
42
+ type: 'codex',
43
+ builtin: true,
44
+ },
45
+ {
46
+ id: 'copilot',
47
+ name: 'GitHub Copilot',
48
+ command: 'copilot',
49
+ args: [],
50
+ resumeArgs: ['--continue'],
51
+ resumeIdArgs: ['--resume', '<id>'],
52
+ shell: 'direct',
53
+ type: 'copilot',
54
+ builtin: true,
55
+ },
56
+ ];
57
+
16
58
  const DEFAULTS = {
17
59
  port: 7777,
18
60
  workDir: path.join(os.homedir(), 'ccsm-workspaces'),
19
- snapshotIntervalMs: 60 * 1000,
20
- snapshotHistoryKeep: 30,
21
- claudeCommand: 'claude',
22
- terminal: 'wt',
23
- commandShell: 'pwsh',
24
- // 'wt' — open a new Windows Terminal window (or whatever `terminal` is set to)
25
- // 'web' — spawn in-process PTY, attach via xterm.js in the Terminals tab
26
- // Used as the default for new sessions, resume, continue, finder.
27
- // Per-launch radio in the UI can still override.
28
- defaultTerminalMode: 'wt',
29
- autoFocusOnLaunch: true,
30
- focusMovesToCenter: false,
31
- // 'app' — Edge/Chrome --app=<url> chromeless window (looks like a desktop app)
32
- // 'tab' — open in default browser as a normal tab
33
- // 'none' — don't open anything
34
- browserMode: 'app',
35
- // Add the repos you most often need on hand. The "new session" button
36
- // clones any selected entries into the workspace before launching claude.
37
- // Example shape:
61
+ // Repos available for cloning into a fresh workspace at launch time.
38
62
  // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
39
63
  repos: [],
40
- finderPrompt:
41
- `Help me find an old Claude Code session on this machine. ccsm's data dir is ${DATA_DIR} (latest snapshot at snapshot.json, history under snapshots/). Live sessions are at ~/.claude/sessions/*.json and conversation transcripts under ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl. Ask me what I'm looking for and grep accordingly.`,
64
+ // Pluggable CLIs. Add wrappers like `ccp` (gc2cc) or self-hosted
65
+ // proxies by appending an entry. defaultCliId picks one for the
66
+ // Launch button when the user doesn't override.
67
+ clis: DEFAULT_CLIS,
68
+ defaultCliId: 'claude',
42
69
  };
43
70
 
44
71
  function ensureDataDir() {
@@ -59,7 +86,6 @@ function migrateLegacyDataIfNeeded() {
59
86
  try {
60
87
  fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
61
88
  console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
62
- console.log(`[ccsm] safe to remove the legacy dir when you're sure: rmdir /s "${LEGACY_DATA_DIR}"`);
63
89
  } catch (e) {
64
90
  console.error('[ccsm] legacy migration failed:', e.message);
65
91
  }
@@ -67,10 +93,60 @@ function migrateLegacyDataIfNeeded() {
67
93
 
68
94
  migrateLegacyDataIfNeeded();
69
95
 
96
+ // Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
97
+ // object so callers don't mutate DEFAULTS.
70
98
  function mergeWithDefaults(partial) {
71
99
  const out = { ...DEFAULTS, ...partial };
72
- if (!Array.isArray(out.repos)) {
73
- out.repos = DEFAULTS.repos;
100
+ // Drop v0.x keys that the new architecture doesn't use.
101
+ delete out.terminal;
102
+ delete out.commandShell;
103
+ delete out.claudeCommand;
104
+ delete out.defaultTerminalMode;
105
+ delete out.autoFocusOnLaunch;
106
+ delete out.focusMovesToCenter;
107
+ delete out.snapshotIntervalMs;
108
+ delete out.snapshotHistoryKeep;
109
+ delete out.autoOpenBrowser;
110
+ delete out.browserMode;
111
+ delete out.finderPrompt;
112
+
113
+ if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
114
+ if (!Array.isArray(out.clis)) out.clis = [];
115
+ // Always inject builtin CLIs (claude, codex) if they're missing or were
116
+ // deleted from a saved config — they're managed by ccsm, the user can
117
+ // tweak args/shell but can't remove them. Preserves any user
118
+ // customisation on existing builtin entries.
119
+ for (const def of DEFAULT_CLIS) {
120
+ const existing = out.clis.find((c) => c.id === def.id);
121
+ if (existing) {
122
+ existing.builtin = true;
123
+ // Backfill defaults from the built-in template for any field the
124
+ // user's saved copy is missing — keeps old configs aligned with new
125
+ // schema additions (resumeArgs, type, etc.) without clobbering the
126
+ // user's customisations.
127
+ if (existing.resumeArgs == null) existing.resumeArgs = def.resumeArgs;
128
+ if (existing.resumeIdArgs == null) existing.resumeIdArgs = def.resumeIdArgs;
129
+ if (!existing.type) existing.type = def.type;
130
+ } else {
131
+ out.clis.unshift({ ...def });
132
+ }
133
+ }
134
+ // Normalize per-CLI fields.
135
+ out.clis = out.clis.map((c) => {
136
+ const { installed, installPath, ...rest } = c; // strip computed probe fields
137
+ return {
138
+ ...rest,
139
+ args: Array.isArray(rest.args) ? rest.args : [],
140
+ resumeArgs: Array.isArray(rest.resumeArgs) ? rest.resumeArgs : [],
141
+ resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
142
+ shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
143
+ type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
144
+ builtin: !!rest.builtin,
145
+ };
146
+ });
147
+ // Make sure defaultCliId points at an actual CLI; fall back to first.
148
+ if (!out.clis.find((c) => c.id === out.defaultCliId)) {
149
+ out.defaultCliId = out.clis[0].id;
74
150
  }
75
151
  return out;
76
152
  }
@@ -105,4 +181,5 @@ module.exports = {
105
181
  CONFIG_PATH,
106
182
  LEGACY_DATA_DIR,
107
183
  DEFAULTS,
184
+ DEFAULT_CLIS,
108
185
  };