@bakapiano/ccsm 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +222 -195
- package/README.md +77 -79
- package/lib/cliSessionWatcher.js +249 -0
- package/lib/config.js +101 -24
- package/lib/folders.js +96 -0
- package/lib/localCliSessions.js +177 -0
- package/lib/persistedSessions.js +134 -0
- package/lib/webTerminal.js +31 -18
- package/lib/workspace.js +26 -4
- package/package.json +1 -1
- package/public/assets/claude-color.svg +1 -0
- package/public/assets/codex-color.svg +1 -0
- package/public/assets/copilot-color.svg +1 -0
- package/public/css/base.css +22 -5
- package/public/css/cards.css +37 -3
- package/public/css/feedback.css +127 -43
- package/public/css/forms.css +97 -25
- package/public/css/layout.css +74 -26
- package/public/css/modal.css +40 -26
- package/public/css/responsive.css +2 -2
- package/public/css/sidebar.css +424 -25
- package/public/css/terminals.css +138 -0
- package/public/css/tokens.css +28 -12
- package/public/css/wco.css +38 -39
- package/public/css/widgets.css +1177 -6
- package/public/index.html +35 -2
- package/public/js/api.js +194 -37
- package/public/js/components/AdoptModal.js +171 -0
- package/public/js/components/App.js +1 -11
- package/public/js/components/DirectoryPicker.js +203 -0
- package/public/js/components/EntityFormModal.js +105 -0
- package/public/js/components/Modal.js +51 -0
- package/public/js/components/OfflineBanner.js +29 -23
- package/public/js/components/PageTitleBar.js +13 -0
- package/public/js/components/Picker.js +179 -0
- package/public/js/components/Popover.js +55 -0
- package/public/js/components/Sidebar.js +219 -32
- package/public/js/components/TerminalView.js +27 -3
- package/public/js/components/useDragSort.js +67 -0
- package/public/js/dialog.js +10 -2
- package/public/js/icons.js +66 -3
- package/public/js/main.js +54 -3
- package/public/js/pages/AboutPage.js +80 -0
- package/public/js/pages/ConfigurePage.js +429 -207
- package/public/js/pages/LaunchPage.js +326 -86
- package/public/js/pages/SessionsPage.js +91 -41
- package/public/js/state.js +102 -73
- package/public/manifest.webmanifest +2 -2
- package/scripts/install.js +7 -2
- package/server.js +755 -441
- package/lib/favorites.js +0 -51
- package/lib/focus.js +0 -369
- package/lib/labels.js +0 -29
- package/lib/launcher.js +0 -219
- package/lib/sessions.js +0 -272
- package/lib/snapshot.js +0 -141
- package/public/js/actions.js +0 -107
- package/public/js/components/Fab.js +0 -11
- package/public/js/components/FavoritesTable.js +0 -81
- package/public/js/components/Footer.js +0 -12
- package/public/js/components/NewSessionModal.js +0 -153
- package/public/js/components/PageHead.js +0 -33
- package/public/js/components/Pagination.js +0 -27
- package/public/js/components/RecentTable.js +0 -68
- package/public/js/components/SessionsTable.js +0 -71
- package/public/js/components/SnapshotPanel.js +0 -77
- package/public/js/components/TitleCell.js +0 -40
- package/public/js/components/WorkspacesGrid.js +0 -41
- package/public/js/pages/TerminalsPage.js +0 -74
package/README.md
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
# ccsm — Claude Code Session Manager
|
|
2
2
|
|
|
3
|
-
A single pane over every
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
[](https://bakapiano.github.io/ccsm/)
|
|
8
9
|
|
|
9
10
|
```
|
|
10
11
|
┌── browser ─────────────────────────┐
|
|
11
|
-
│ https://bakapiano.github.io/ccsm/
|
|
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/
|
|
19
|
-
│ ├── /api/sessions/
|
|
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
|
-
- **
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
|
55
|
-
If the backend isn't running,
|
|
56
|
-
with a **Start ccsm** button — click it, Windows asks once
|
|
57
|
-
open the `ccsm://` handler (check "Always allow"), and the
|
|
58
|
-
spawns silently behind the page. The
|
|
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
|
-
|
|
|
77
|
-
|
|
|
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.
|
|
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
|
-
│ ├──
|
|
97
|
-
│ ├──
|
|
98
|
-
│ ├──
|
|
99
|
-
│ ├──
|
|
100
|
-
│ ├──
|
|
101
|
-
│ ├── webTerminal.js #
|
|
102
|
-
│ ├──
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
113
|
-
├──
|
|
114
|
-
├──
|
|
115
|
-
├──
|
|
116
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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 (
|
|
187
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
};
|