@bakapiano/ccsm 0.6.0 → 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 +377 -123
- package/README.md +172 -38
- package/bin/ccsm.js +194 -0
- package/lib/favorites.js +23 -45
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +21 -41
- package/lib/webTerminal.js +173 -0
- package/package.json +11 -3
- package/public/css/base.css +82 -0
- package/public/css/cards.css +149 -0
- package/public/css/feedback.css +219 -0
- package/public/css/forms.css +282 -0
- package/public/css/layout.css +107 -0
- package/public/css/modal.css +169 -0
- package/public/css/responsive.css +10 -0
- package/public/css/sidebar.css +165 -0
- package/public/css/tables.css +266 -0
- package/public/css/terminals.css +112 -0
- package/public/css/tokens.css +63 -0
- package/public/css/wco.css +70 -0
- package/public/css/widgets.css +204 -0
- package/public/favicon.svg +1 -1
- package/public/index.html +52 -490
- package/public/js/actions.js +87 -0
- package/public/js/api.js +103 -0
- package/public/js/backend.js +28 -0
- package/public/js/components/App.js +45 -0
- package/public/js/components/Card.js +24 -0
- package/public/js/components/DialogHost.js +45 -0
- package/public/js/components/Fab.js +11 -0
- package/public/js/components/FavoritesTable.js +81 -0
- package/public/js/components/Footer.js +12 -0
- package/public/js/components/NewSessionModal.js +142 -0
- package/public/js/components/OfflineBanner.js +52 -0
- package/public/js/components/PageHead.js +33 -0
- package/public/js/components/Pagination.js +27 -0
- package/public/js/components/ProgressList.js +32 -0
- package/public/js/components/RecentTable.js +68 -0
- package/public/js/components/RepoPicker.js +40 -0
- package/public/js/components/ReposEditor.js +74 -0
- package/public/js/components/ServerStatus.js +18 -0
- package/public/js/components/SessionsTable.js +71 -0
- package/public/js/components/Sidebar.js +52 -0
- package/public/js/components/SnapshotPanel.js +77 -0
- package/public/js/components/TerminalView.js +108 -0
- package/public/js/components/TitleCell.js +40 -0
- package/public/js/components/Toast.js +8 -0
- package/public/js/components/WorkspacePicker.js +19 -0
- package/public/js/components/WorkspacesGrid.js +41 -0
- package/public/js/dialog.js +59 -0
- package/public/js/html.js +6 -0
- package/public/js/icons.js +114 -0
- package/public/js/main.js +81 -0
- package/public/js/pages/AboutPage.js +85 -0
- package/public/js/pages/ConfigurePage.js +194 -0
- package/public/js/pages/LaunchPage.js +117 -0
- package/public/js/pages/SessionsPage.js +47 -0
- package/public/js/pages/TerminalsPage.js +74 -0
- package/public/js/state.js +87 -0
- package/public/js/streaming.js +96 -0
- package/public/js/toast.js +14 -0
- package/public/js/util.js +24 -0
- package/public/manifest.webmanifest +14 -0
- package/scripts/install.js +132 -0
- package/scripts/uninstall.js +56 -0
- package/server.js +286 -30
- package/public/app.js +0 -1353
- package/public/styles.css +0 -1639
package/README.md
CHANGED
|
@@ -1,58 +1,192 @@
|
|
|
1
1
|
# ccsm — Claude Code Session Manager
|
|
2
2
|
|
|
3
|
-
A
|
|
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.
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
- Snapshots the full session set every minute (`data/snapshot.json` + rotated history under `data/snapshots/`). One click **restores** the snapshot — one new wt window per session, `cd` + `claude --resume`.
|
|
7
|
-
- **New session** picks an unused workspace under your work directory, clones the repos you selected (streaming live `git clone --progress` to a per-repo progress bar in the UI), then opens a fresh terminal window running `claude` (or whichever command you set).
|
|
8
|
-
- **Ask Claude to find a session** opens a Claude Code session pre-pointed at this repo so you can grep through past conversations.
|
|
7
|
+
[](https://bakapiano.github.io/cssm/v1/)
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
```
|
|
10
|
+
┌── browser ─────────────────────────┐
|
|
11
|
+
│ https://bakapiano.github.io/cssm/v1/ ← static frontend
|
|
12
|
+
└────────────┬───────────────────────┘
|
|
13
|
+
│ fetch /api/* (CORS)
|
|
14
|
+
│ ws://localhost:7777/ws/*
|
|
15
|
+
▼
|
|
16
|
+
┌── local backend ───────────────────┐
|
|
17
|
+
│ ccsm (npm bin) │
|
|
18
|
+
│ ├── /api/sessions /api/snapshot │
|
|
19
|
+
│ ├── /api/sessions/new (NDJSON) │
|
|
20
|
+
│ ├── /ws/terminal/:id (PTY) │
|
|
21
|
+
│ └── /api/health /api/heartbeat │
|
|
22
|
+
└────────────────────────────────────┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What it does
|
|
26
|
+
|
|
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.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm i -g @bakapiano/ccsm
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This:
|
|
41
|
+
- puts `ccsm` on your PATH
|
|
42
|
+
- registers a `ccsm://` URL protocol so the hosted frontend can wake the
|
|
43
|
+
backend with one click
|
|
44
|
+
|
|
45
|
+
`npx @bakapiano/ccsm` works too for a one-shot trial — the protocol still
|
|
46
|
+
gets registered.
|
|
47
|
+
|
|
48
|
+
## Use
|
|
11
49
|
|
|
12
|
-
```
|
|
13
|
-
#
|
|
14
|
-
npx github:bakapiano/cssm
|
|
15
|
-
# open http://localhost:7777
|
|
50
|
+
```bash
|
|
51
|
+
ccsm # starts the backend, opens the frontend
|
|
16
52
|
```
|
|
17
53
|
|
|
18
|
-
Or
|
|
54
|
+
Or just visit **https://bakapiano.github.io/cssm/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.
|
|
19
59
|
|
|
20
|
-
|
|
21
|
-
|
|
60
|
+
### Install as PWA
|
|
61
|
+
|
|
62
|
+
In Chrome / Edge, click the install icon in the address bar (or use the
|
|
63
|
+
"Install ccsm" button on the **About** tab inside the app). The PWA gets
|
|
64
|
+
its own window, its own icon, and Window Controls Overlay so the title
|
|
65
|
+
bar blends into the page.
|
|
66
|
+
|
|
67
|
+
After installing, clicking the PWA icon is the new entry point — no
|
|
68
|
+
terminal needed.
|
|
69
|
+
|
|
70
|
+
## Defaults
|
|
71
|
+
|
|
72
|
+
| | |
|
|
73
|
+
|---|---|
|
|
74
|
+
| Port | `7777` (auto-bumps if taken) |
|
|
75
|
+
| 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 |
|
|
81
|
+
|
|
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.
|
|
85
|
+
|
|
86
|
+
## Layout
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
ccsm/
|
|
90
|
+
├── server.js # Express + WebSocket; API only in prod
|
|
91
|
+
├── bin/ccsm.js # launcher · detaches server, opens browser
|
|
92
|
+
├── scripts/
|
|
93
|
+
│ ├── install.js # postinstall · registers ccsm:// (Windows)
|
|
94
|
+
│ └── uninstall.js # preuninstall · cleanup
|
|
95
|
+
├── 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)
|
|
110
|
+
|
|
111
|
+
~/.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
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## How "wake on click" works
|
|
121
|
+
|
|
122
|
+
The hosted frontend (https://bakapiano.github.io/cssm/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:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
HKCU\Software\Classes\ccsm\shell\open\command
|
|
130
|
+
→ wscript.exe "<LOCALAPPDATA>\ccsm\launcher.vbs" "%1"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
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.
|
|
136
|
+
|
|
137
|
+
First click triggers a one-time Windows dialog ("Open ccsm.cmd?"). Tick
|
|
138
|
+
**Always allow** and future clicks are silent.
|
|
139
|
+
|
|
140
|
+
## Lifecycle (when does the backend die)
|
|
141
|
+
|
|
142
|
+
| trigger | reaction |
|
|
143
|
+
|---|---|
|
|
144
|
+
| The auto-opened browser window closes | wait 12s · if any other client heartbeats during that window, stay alive; otherwise gracefulShutdown |
|
|
145
|
+
| No heartbeat for 90s | gracefulShutdown |
|
|
146
|
+
| `POST /api/shutdown` | gracefulShutdown |
|
|
147
|
+
| SIGINT / SIGTERM | gracefulShutdown |
|
|
148
|
+
|
|
149
|
+
Every gracefulShutdown saves a final snapshot before exit.
|
|
150
|
+
|
|
151
|
+
## Dev
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
git clone https://github.com/bakapiano/cssm
|
|
22
155
|
cd cssm
|
|
23
156
|
npm install
|
|
24
157
|
node server.js
|
|
158
|
+
# opens http://localhost:7777 with hot-reload (public/ is served locally
|
|
159
|
+
# and SSE pushes a reload event on every file save)
|
|
25
160
|
```
|
|
26
161
|
|
|
27
|
-
|
|
162
|
+
Dev mode is detected via `__dirname.includes('node_modules')` — when
|
|
163
|
+
running from a checkout, the backend also serves `public/`. In an
|
|
164
|
+
npm-installed copy it's API-only, and you use the hosted frontend.
|
|
28
165
|
|
|
29
|
-
|
|
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
|
+
## Versioning (frontend ↔ backend)
|
|
171
|
+
|
|
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.
|
|
30
176
|
|
|
31
177
|
```
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
├── lib\
|
|
35
|
-
│ ├── sessions.js # ~/.claude/sessions/*.json + live PID cross-check via tasklist
|
|
36
|
-
│ ├── snapshot.js # save / load / rotate / restore
|
|
37
|
-
│ ├── workspace.js # workspace = subfolder under workDir; clone repos with progress
|
|
38
|
-
│ ├── launcher.js # dispatch across wt / powershell / pwsh / cmd
|
|
39
|
-
│ ├── focus.js # PowerShell + Win32 — listWindowsOf, focusByHwnd, focusByPid
|
|
40
|
-
│ └── config.js # load/save data/config.json
|
|
41
|
-
├── public\ # vanilla HTML/JS frontend, auto-refresh every 5s
|
|
42
|
-
~/.ccsm/ # (or $CCSM_HOME)
|
|
43
|
-
├── config.json # source of truth
|
|
44
|
-
├── snapshot.json # latest auto-snapshot
|
|
45
|
-
└── snapshots/ # rotating history
|
|
178
|
+
https://bakapiano.github.io/cssm/v1/ ← current
|
|
179
|
+
https://bakapiano.github.io/cssm/v2/ ← future, when /api breaking-changes
|
|
46
180
|
```
|
|
47
181
|
|
|
48
|
-
|
|
182
|
+
Installed PWAs are pinned to whichever path they were installed from.
|
|
183
|
+
|
|
184
|
+
## Status
|
|
49
185
|
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
-
|
|
54
|
-
- Auto-focus on launch: on (HWND diff across the terminal process — works for modern wt's multi-window-single-process layout).
|
|
55
|
-
- Snapshot interval: 60s; last 30 kept.
|
|
56
|
-
- Default repos: none — add your own through the Config panel (URL is whatever `git clone` accepts).
|
|
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).
|
|
189
|
+
- Frontend: cross-platform (pure web).
|
|
57
190
|
|
|
58
|
-
See [CLAUDE.md](CLAUDE.md) for design decisions and the non-obvious
|
|
191
|
+
See [CLAUDE.md](CLAUDE.md) for design decisions and the non-obvious
|
|
192
|
+
gotchas baked into the launcher / focus / snapshot code.
|
package/bin/ccsm.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ccsm launcher · entry point for `ccsm` / `npx @bakapiano/ccsm`.
|
|
5
|
+
//
|
|
6
|
+
// Two modes by how it's invoked:
|
|
7
|
+
//
|
|
8
|
+
// plain `ccsm` → start backend if not running, open a browser
|
|
9
|
+
// window pointing at it. Terminal returns to a
|
|
10
|
+
// prompt immediately (detached).
|
|
11
|
+
//
|
|
12
|
+
// `ccsm ccsm://<action>` → fired by Windows when the user clicks a
|
|
13
|
+
// ccsm:// link (PWA offline banner). Same
|
|
14
|
+
// backend startup as above, but DO NOT spawn
|
|
15
|
+
// an extra browser — the PWA window that
|
|
16
|
+
// triggered the click is already open and
|
|
17
|
+
// will reconnect as soon as the backend
|
|
18
|
+
// becomes reachable.
|
|
19
|
+
//
|
|
20
|
+
// In both modes, if a server is already running we just ping it. New
|
|
21
|
+
// browser window opens only in the plain-`ccsm` case.
|
|
22
|
+
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const http = require('node:http');
|
|
27
|
+
const { spawn } = require('node:child_process');
|
|
28
|
+
|
|
29
|
+
const SERVER = path.join(__dirname, '..', 'server.js');
|
|
30
|
+
const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
31
|
+
const LOG = path.join(HOME, 'server.log');
|
|
32
|
+
|
|
33
|
+
function loadPreferredPort() {
|
|
34
|
+
try {
|
|
35
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(HOME, 'config.json'), 'utf8'));
|
|
36
|
+
return Number(cfg.port) || 7777;
|
|
37
|
+
} catch {
|
|
38
|
+
return 7777;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function probe(port, timeoutMs = 800) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
|
|
45
|
+
let body = '';
|
|
46
|
+
res.on('data', (c) => body += c);
|
|
47
|
+
res.on('end', () => {
|
|
48
|
+
try {
|
|
49
|
+
const j = JSON.parse(body);
|
|
50
|
+
resolve(j && j.name === '@bakapiano/ccsm' ? j : null);
|
|
51
|
+
} catch { resolve(null); }
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
req.on('error', () => resolve(null));
|
|
55
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function post(port, pathname, timeoutMs = 2000) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const req = http.request({
|
|
62
|
+
hostname: 'localhost', port, path: pathname, method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
64
|
+
timeout: timeoutMs,
|
|
65
|
+
}, (res) => {
|
|
66
|
+
res.resume();
|
|
67
|
+
res.on('end', () => resolve(res.statusCode < 300));
|
|
68
|
+
});
|
|
69
|
+
req.on('error', () => resolve(false));
|
|
70
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
71
|
+
req.write('{}');
|
|
72
|
+
req.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Detect ccsm:// protocol invocation. Windows runs us as
|
|
77
|
+
// `ccsm.cmd ccsm://start` when the user clicks a protocol link.
|
|
78
|
+
// argv layout: [node, ccsm.js, "ccsm://..."]
|
|
79
|
+
function parseProtocolArg() {
|
|
80
|
+
const a = process.argv[2];
|
|
81
|
+
if (!a || !/^ccsm:\/\//i.test(a)) return null;
|
|
82
|
+
try {
|
|
83
|
+
// Normalise: ccsm://start or ccsm://start?foo=bar
|
|
84
|
+
const u = new URL(a);
|
|
85
|
+
// host is the action (`start`, `restart`, ...); empty host means
|
|
86
|
+
// the URL was `ccsm:start` or `ccsm:///action`
|
|
87
|
+
const action = (u.hostname || u.pathname.replace(/^\/+/, '').split('/')[0] || '').toLowerCase();
|
|
88
|
+
return { action, raw: a };
|
|
89
|
+
} catch {
|
|
90
|
+
return { action: '', raw: a };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Compare what's running with what's installed. Returns true if they
|
|
95
|
+
// match (or running is unknown). False means we should restart so the
|
|
96
|
+
// new code takes over after an `npm i -g @bakapiano/ccsm@latest`.
|
|
97
|
+
function isSameVersion(running) {
|
|
98
|
+
try {
|
|
99
|
+
const installed = require('../package.json').version;
|
|
100
|
+
return running.version === installed;
|
|
101
|
+
} catch { return true; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
(async () => {
|
|
105
|
+
const protocol = parseProtocolArg();
|
|
106
|
+
const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
|
|
107
|
+
const port = loadPreferredPort();
|
|
108
|
+
|
|
109
|
+
// Case 1: existing instance on the preferred port
|
|
110
|
+
let existing = await probe(port);
|
|
111
|
+
|
|
112
|
+
// If an old version is running, ask it to shut down so the freshly
|
|
113
|
+
// installed code can take over. The launcher then falls through to
|
|
114
|
+
// Case 2 and spawns the new server itself.
|
|
115
|
+
if (existing && !isSameVersion(existing)) {
|
|
116
|
+
const installed = require('../package.json').version;
|
|
117
|
+
console.log(`ccsm upgrading · running v${existing.version} → installed v${installed}`);
|
|
118
|
+
await post(port, '/api/shutdown');
|
|
119
|
+
// Wait for the old process to actually exit so its port frees up.
|
|
120
|
+
for (let i = 0; i < 30; i++) {
|
|
121
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
122
|
+
if (!(await probe(port, 200))) { existing = null; break; }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (existing) {
|
|
127
|
+
if (!SILENT) {
|
|
128
|
+
const opened = await post(port, '/api/spawn-browser');
|
|
129
|
+
console.log(`ccsm already running · v${existing.version} · http://localhost:${port}`);
|
|
130
|
+
if (!opened) console.log('(could not open a new window — server might be busy)');
|
|
131
|
+
} else {
|
|
132
|
+
console.log(`ccsm already running · ${protocol.raw}`);
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Case 2: spawn detached server
|
|
138
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
139
|
+
const out = fs.openSync(LOG, 'a');
|
|
140
|
+
fs.writeSync(out, `\n[${new Date().toISOString()}] ccsm starting (protocol=${protocol?.raw || '-'})...\n`);
|
|
141
|
+
|
|
142
|
+
const child = spawn(process.execPath, [SERVER], {
|
|
143
|
+
detached: true,
|
|
144
|
+
stdio: ['ignore', out, out],
|
|
145
|
+
windowsHide: true,
|
|
146
|
+
env: {
|
|
147
|
+
...process.env,
|
|
148
|
+
CCSM_LAUNCHER: '1',
|
|
149
|
+
// Suppress the server's own auto-spawn of a browser when this launch
|
|
150
|
+
// came from a ccsm:// click — the PWA window that fired it is the
|
|
151
|
+
// browser, and a second window would just be noise.
|
|
152
|
+
...(SILENT ? { CCSM_NO_BROWSER: '1' } : {}),
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
child.unref();
|
|
156
|
+
|
|
157
|
+
// Poll /api/health for up to ~10s. Once it answers we know the server
|
|
158
|
+
// is fully booted (port is bound, config loaded, snapshot loop running).
|
|
159
|
+
// The actual port may differ from the preferred one if it was taken,
|
|
160
|
+
// so on each iteration we re-probe the preferred port first, then fall
|
|
161
|
+
// back to scanning preferred+1..preferred+9.
|
|
162
|
+
const portsToTry = [port, ...Array.from({ length: 9 }, (_, i) => port + i + 1)];
|
|
163
|
+
let actualPort = null;
|
|
164
|
+
let ready = null;
|
|
165
|
+
outer:
|
|
166
|
+
for (let i = 0; i < 50; i++) {
|
|
167
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
168
|
+
for (const p of portsToTry) {
|
|
169
|
+
const r = await probe(p, 300);
|
|
170
|
+
if (r) { ready = r; actualPort = p; break outer; }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!ready) {
|
|
174
|
+
console.error(`ccsm server did not come up in 10s. Check ${LOG}`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
console.log(`ccsm started · v${ready.version}`);
|
|
178
|
+
console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
|
|
179
|
+
console.log(`frontend: https://bakapiano.github.io/cssm/v1/`);
|
|
180
|
+
console.log(`logs: ${LOG}`);
|
|
181
|
+
|
|
182
|
+
// First-run hint — printed once, then a marker file makes us quiet.
|
|
183
|
+
const firstRunMark = path.join(HOME, '.first-run-shown');
|
|
184
|
+
if (!fs.existsSync(firstRunMark)) {
|
|
185
|
+
try { fs.writeFileSync(firstRunMark, new Date().toISOString()); } catch {}
|
|
186
|
+
console.log('');
|
|
187
|
+
console.log('First run · ccsm is now running in the background.');
|
|
188
|
+
console.log('Open the frontend URL above, click "Install ccsm" in your browser');
|
|
189
|
+
console.log('to install it as a PWA so the icon launches directly into the app.');
|
|
190
|
+
}
|
|
191
|
+
})().catch((err) => {
|
|
192
|
+
console.error('ccsm launcher failed:', err);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
package/lib/favorites.js
CHANGED
|
@@ -1,41 +1,23 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// User-pinned ("favorited") sessions
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// User-pinned ("favorited") sessions, keyed by sessionId at
|
|
4
|
+
// $DATA_DIR/favorites.json. Each entry captures enough metadata
|
|
5
|
+
// (cwd, title, gitBranch) to render the row even after the session's
|
|
6
|
+
// jsonl is gone — entries are best-effort archival.
|
|
7
7
|
|
|
8
|
-
const fs = require('node:fs/promises');
|
|
9
|
-
const path = require('node:path');
|
|
10
8
|
const { DATA_DIR } = require('./config');
|
|
9
|
+
const { createKeyedJsonStore } = require('./jsonStore');
|
|
11
10
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const raw = await fs.readFile(FAVORITES_PATH, 'utf8');
|
|
17
|
-
const obj = JSON.parse(raw);
|
|
18
|
-
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
|
19
|
-
} catch (e) {
|
|
20
|
-
if (e.code === 'ENOENT') return {};
|
|
21
|
-
throw e;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function saveFavorites(favs) {
|
|
26
|
-
await fs.writeFile(FAVORITES_PATH, JSON.stringify(favs, null, 2));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function listFavorites() {
|
|
30
|
-
const favs = await loadFavorites();
|
|
31
|
-
return Object.values(favs).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
|
|
32
|
-
}
|
|
11
|
+
const store = createKeyedJsonStore({
|
|
12
|
+
dataDir: DATA_DIR,
|
|
13
|
+
filename: 'favorites.json',
|
|
14
|
+
});
|
|
33
15
|
|
|
34
16
|
async function addFavorite(sessionId, info = {}) {
|
|
35
17
|
if (!sessionId) throw new Error('addFavorite: sessionId required');
|
|
36
|
-
const
|
|
37
|
-
const existing =
|
|
38
|
-
|
|
18
|
+
const map = await store.load();
|
|
19
|
+
const existing = map[sessionId];
|
|
20
|
+
const next = existing
|
|
39
21
|
? { ...existing, ...info, sessionId }
|
|
40
22
|
: {
|
|
41
23
|
sessionId,
|
|
@@ -45,29 +27,25 @@ async function addFavorite(sessionId, info = {}) {
|
|
|
45
27
|
label: info.label || null,
|
|
46
28
|
addedAt: Date.now(),
|
|
47
29
|
};
|
|
48
|
-
|
|
49
|
-
return favs[sessionId];
|
|
30
|
+
return store.set(sessionId, next);
|
|
50
31
|
}
|
|
51
32
|
|
|
52
|
-
async function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
delete favs[sessionId];
|
|
56
|
-
await saveFavorites(favs);
|
|
57
|
-
return true;
|
|
33
|
+
async function hasFavorite(sessionId) {
|
|
34
|
+
const map = await store.load();
|
|
35
|
+
return sessionId in map;
|
|
58
36
|
}
|
|
59
37
|
|
|
60
|
-
async function
|
|
61
|
-
const
|
|
62
|
-
return
|
|
38
|
+
async function listFavorites() {
|
|
39
|
+
const list = await store.list();
|
|
40
|
+
return list.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
|
|
63
41
|
}
|
|
64
42
|
|
|
65
43
|
module.exports = {
|
|
66
|
-
loadFavorites,
|
|
67
|
-
saveFavorites,
|
|
44
|
+
loadFavorites: store.load,
|
|
45
|
+
saveFavorites: store.save,
|
|
68
46
|
listFavorites,
|
|
69
47
|
addFavorite,
|
|
70
|
-
removeFavorite,
|
|
48
|
+
removeFavorite: store.remove,
|
|
71
49
|
hasFavorite,
|
|
72
|
-
FAVORITES_PATH,
|
|
50
|
+
FAVORITES_PATH: store.filePath,
|
|
73
51
|
};
|
package/lib/jsonStore.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Factory for a keyed-JSON store under $DATA_DIR. Both favorites and labels
|
|
4
|
+
// have the same shape: a JSON object keyed by sessionId, written atomically
|
|
5
|
+
// on each mutation, with ENOENT swallowed to empty.
|
|
6
|
+
//
|
|
7
|
+
// const store = createKeyedJsonStore({ filename: 'foo.json', transformValue: (v) => ... })
|
|
8
|
+
// await store.load() → object
|
|
9
|
+
// await store.set(key, v) → returns the stored value (or null if removed)
|
|
10
|
+
// await store.remove(key) → returns true if it existed
|
|
11
|
+
// await store.list() → array of values
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs/promises');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v }) {
|
|
17
|
+
const filePath = path.join(dataDir, filename);
|
|
18
|
+
|
|
19
|
+
async function load() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
22
|
+
const obj = JSON.parse(raw);
|
|
23
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e.code === 'ENOENT') return {};
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function save(map) {
|
|
31
|
+
await fs.writeFile(filePath, JSON.stringify(map, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function set(key, value) {
|
|
35
|
+
if (!key) throw new Error('set: key required');
|
|
36
|
+
const next = transformValue(value, key);
|
|
37
|
+
if (next == null) return remove(key);
|
|
38
|
+
const map = await load();
|
|
39
|
+
map[key] = next;
|
|
40
|
+
await save(map);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function remove(key) {
|
|
45
|
+
const map = await load();
|
|
46
|
+
if (!(key in map)) return false;
|
|
47
|
+
delete map[key];
|
|
48
|
+
await save(map);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function list() {
|
|
53
|
+
const map = await load();
|
|
54
|
+
return Object.values(map);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { load, save, set, remove, list, filePath };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { createKeyedJsonStore };
|
package/lib/labels.js
CHANGED
|
@@ -1,49 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// User-defined display titles for sessions
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// User-defined display titles for sessions, keyed by sessionId at
|
|
4
|
+
// $DATA_DIR/labels.json. Frontend overlays the label on top of the
|
|
5
|
+
// AI-generated title when rendering live / recent / favorites.
|
|
6
6
|
|
|
7
|
-
const fs = require('node:fs/promises');
|
|
8
7
|
const path = require('node:path');
|
|
9
8
|
const { DATA_DIR } = require('./config');
|
|
9
|
+
const { createKeyedJsonStore } = require('./jsonStore');
|
|
10
10
|
|
|
11
|
-
const LABELS_PATH = path.join(DATA_DIR, 'labels.json');
|
|
12
11
|
const MAX_LEN = 200;
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const trimmed = String(label || '').trim().slice(0, MAX_LEN);
|
|
32
|
-
if (!trimmed) {
|
|
33
|
-
return removeLabel(sessionId);
|
|
34
|
-
}
|
|
35
|
-
const labels = await loadLabels();
|
|
36
|
-
labels[sessionId] = trimmed;
|
|
37
|
-
await saveLabels(labels);
|
|
38
|
-
return trimmed;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function removeLabel(sessionId) {
|
|
42
|
-
const labels = await loadLabels();
|
|
43
|
-
if (!(sessionId in labels)) return false;
|
|
44
|
-
delete labels[sessionId];
|
|
45
|
-
await saveLabels(labels);
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = { loadLabels, saveLabels, setLabel, removeLabel, LABELS_PATH };
|
|
13
|
+
const store = createKeyedJsonStore({
|
|
14
|
+
dataDir: DATA_DIR,
|
|
15
|
+
filename: 'labels.json',
|
|
16
|
+
// Empty / null label triggers a remove via the factory contract.
|
|
17
|
+
transformValue: (v) => {
|
|
18
|
+
const trimmed = String(v || '').trim().slice(0, MAX_LEN);
|
|
19
|
+
return trimmed || null;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
loadLabels: store.load,
|
|
25
|
+
saveLabels: store.save,
|
|
26
|
+
setLabel: store.set,
|
|
27
|
+
removeLabel: store.remove,
|
|
28
|
+
LABELS_PATH: store.filePath,
|
|
29
|
+
};
|