@bakapiano/ccsm 0.15.0 → 0.15.2
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 +60 -0
- package/lib/cliActivity.js +24 -3
- package/lib/config.js +13 -19
- package/package.json +1 -1
- package/public/css/sidebar.css +42 -1
- package/public/js/pages/AboutPage.js +9 -1
- package/public/js/pages/ConfigurePage.js +9 -1
- package/scripts/restart-helper.js +6 -1
- package/scripts/upgrade-helper.js +6 -1
- package/server.js +11 -4
package/CLAUDE.md
CHANGED
|
@@ -443,6 +443,66 @@ fresh server. So `npm i -g @bakapiano/ccsm@latest && ccsm` is one
|
|
|
443
443
|
seamless step. From the frontend, the About page's Upgrade button
|
|
444
444
|
achieves the same thing without leaving the browser.
|
|
445
445
|
|
|
446
|
+
### Release process
|
|
447
|
+
|
|
448
|
+
Three artifacts ship per release: a git tag, a GitHub Release, and an
|
|
449
|
+
npm publish. The whole thing is CI-driven — you never `npm publish`
|
|
450
|
+
locally — but it requires you to drive three steps in order:
|
|
451
|
+
|
|
452
|
+
1. **Commit + bump + push (local).** Stage everything, write a release
|
|
453
|
+
commit, then bump + tag + push:
|
|
454
|
+
|
|
455
|
+
```powershell
|
|
456
|
+
git add -A
|
|
457
|
+
git commit -m "vX.Y.Z: <one-line summary>
|
|
458
|
+
|
|
459
|
+
<body>
|
|
460
|
+
|
|
461
|
+
Co-Authored-By: Claude ..."
|
|
462
|
+
npm --prefix . version <patch|minor|major> -m "v%s"
|
|
463
|
+
git push origin main
|
|
464
|
+
git push origin vX.Y.Z
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
`npm version` writes the new version into `package.json` +
|
|
468
|
+
`package-lock.json`, creates its OWN commit, and tags it. The
|
|
469
|
+
`--prefix .` is needed on Windows where bare `npm version` errors on
|
|
470
|
+
the global `%APPDATA%\npm\package.json`. Push BOTH `main` and the
|
|
471
|
+
tag — pushing only main skips the tag-triggered draft-release
|
|
472
|
+
workflow.
|
|
473
|
+
|
|
474
|
+
2. **Tag-push fires two workflows automatically:**
|
|
475
|
+
- `Deploy frontend to GitHub Pages` → publishes `pages-root/` → `/`
|
|
476
|
+
and `public/` → `/<X.Y.Z>/` on `gh-pages`. Old `/<X.Y.Z>/`
|
|
477
|
+
subdirs stay forever (`keep_files: true`).
|
|
478
|
+
- `Draft GitHub Release on tag push` → creates a **draft** release
|
|
479
|
+
for `vX.Y.Z`.
|
|
480
|
+
|
|
481
|
+
3. **Publish the draft (manual one-liner):**
|
|
482
|
+
|
|
483
|
+
```powershell
|
|
484
|
+
gh release edit vX.Y.Z --draft=false
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
This flips the draft to "published", which fires the third workflow
|
|
488
|
+
— `Publish to npm` — using the `NPM_TOKEN` repo secret with
|
|
489
|
+
provenance. The runner needs ~30s; verify with
|
|
490
|
+
`gh run watch <run-id> --exit-status` or just refresh npmjs.com.
|
|
491
|
+
|
|
492
|
+
The reason for the draft step instead of auto-publishing on tag push:
|
|
493
|
+
gives you a chance to abort a half-baked tag (delete the draft +
|
|
494
|
+
`git push --delete origin vX.Y.Z`) before it lands on the public
|
|
495
|
+
registry.
|
|
496
|
+
|
|
497
|
+
### Why we don't publish from the local box
|
|
498
|
+
|
|
499
|
+
`npm publish` from a dev machine works in principle but skips
|
|
500
|
+
provenance attestation (the sigstore + GitHub OIDC binding that npm
|
|
501
|
+
displays as a "Provenance" badge on the package page). CI has the OIDC
|
|
502
|
+
token; you don't. Local publish also wouldn't have the consistent
|
|
503
|
+
runner state, so reproducible-build claims fall apart. The pipeline
|
|
504
|
+
exists; use it.
|
|
505
|
+
|
|
446
506
|
## Cross-platform
|
|
447
507
|
|
|
448
508
|
Today: Windows-first.
|
package/lib/cliActivity.js
CHANGED
|
@@ -90,9 +90,20 @@ async function resolveTranscript(record, cliCfg) {
|
|
|
90
90
|
async function probeActivity(record, cliCfg) {
|
|
91
91
|
let s = state.get(record.id);
|
|
92
92
|
if (!s) {
|
|
93
|
-
s = { resolvedPath: null, lastMtimeMs: 0, lastChangedAt: 0 };
|
|
93
|
+
s = { resolvedPath: null, lastMtimeMs: 0, lastChangedAt: 0, lastOutputAt: 0 };
|
|
94
94
|
state.set(record.id, s);
|
|
95
95
|
}
|
|
96
|
+
// PTY output (CLI is streaming text — thinking spinners, token output,
|
|
97
|
+
// status lines) is the strongest signal that the CLI is working. It's
|
|
98
|
+
// ALSO the only signal we have when the transcript file isn't being
|
|
99
|
+
// updated — claude/codex buffer reasoning + tool results for tens of
|
|
100
|
+
// seconds before flushing a turn, so mtime alone reports "idle"
|
|
101
|
+
// through long thinking phases. Check PTY first; short-circuit if the
|
|
102
|
+
// CLI is clearly active, skipping the fs.stat below.
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
if (s.lastOutputAt && (now - s.lastOutputAt) < WORKING_WINDOW_MS) {
|
|
105
|
+
return 'working';
|
|
106
|
+
}
|
|
96
107
|
if (!s.resolvedPath) {
|
|
97
108
|
s.resolvedPath = await resolveTranscript(record, cliCfg);
|
|
98
109
|
if (!s.resolvedPath) return 'unknown';
|
|
@@ -105,7 +116,6 @@ async function probeActivity(record, cliCfg) {
|
|
|
105
116
|
s.resolvedPath = null;
|
|
106
117
|
return 'unknown';
|
|
107
118
|
}
|
|
108
|
-
const now = Date.now();
|
|
109
119
|
if (mtimeMs !== s.lastMtimeMs) {
|
|
110
120
|
s.lastMtimeMs = mtimeMs;
|
|
111
121
|
s.lastChangedAt = now;
|
|
@@ -113,6 +123,17 @@ async function probeActivity(record, cliCfg) {
|
|
|
113
123
|
return (now - s.lastChangedAt) < WORKING_WINDOW_MS ? 'working' : 'idle';
|
|
114
124
|
}
|
|
115
125
|
|
|
126
|
+
// Called from server.js's spawnCliSession onData hook. Cheap (timestamp
|
|
127
|
+
// write); bound by how often the PTY emits, which is fine.
|
|
128
|
+
function noteOutput(sessionId) {
|
|
129
|
+
let s = state.get(sessionId);
|
|
130
|
+
if (!s) {
|
|
131
|
+
s = { resolvedPath: null, lastMtimeMs: 0, lastChangedAt: 0, lastOutputAt: 0 };
|
|
132
|
+
state.set(sessionId, s);
|
|
133
|
+
}
|
|
134
|
+
s.lastOutputAt = Date.now();
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
function releaseSession(sessionId) { state.delete(sessionId); }
|
|
117
138
|
|
|
118
|
-
module.exports = { probeActivity, releaseSession };
|
|
139
|
+
module.exports = { probeActivity, noteOutput, releaseSession };
|
package/lib/config.js
CHANGED
|
@@ -130,26 +130,20 @@ function mergeWithDefaults(partial) {
|
|
|
130
130
|
if (existing) {
|
|
131
131
|
existing.builtin = true;
|
|
132
132
|
// Backfill defaults from the built-in template for any field the
|
|
133
|
-
// user's saved copy is missing
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
133
|
+
// user's saved copy is missing OR has as an empty array. Empty
|
|
134
|
+
// arrays matter because users upgrading from a pre-0.15 config
|
|
135
|
+
// never wrote `newSessionIdArgs` (didn't exist), AND a partial
|
|
136
|
+
// 0.14→0.15 dev iteration shipped codex with `[]`. Treat both
|
|
137
|
+
// the same: a builtin with no template means "use the canonical
|
|
138
|
+
// one ccsm now knows about", since these fields are the
|
|
139
|
+
// integration contract with the upstream CLI — not user knobs.
|
|
140
|
+
const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
|
|
141
|
+
if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
|
|
142
|
+
if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
|
|
143
|
+
// Drop the v0.x `resumeArgs` fallback — every builtin now has a
|
|
144
|
+
// pre-assigned UUID (claude/copilot via flag, codex via seed) so
|
|
145
|
+
// resumeIdArgs always applies on resume; the field is dead weight.
|
|
141
146
|
delete existing.resumeArgs;
|
|
142
|
-
// Special-case codex: an unreleased earlier iteration of this
|
|
143
|
-
// schema shipped `newSessionIdArgs: []` for codex. The seeded-file
|
|
144
|
-
// trick (lib/codexSeed) now lets us pre-assign, so backfill the
|
|
145
|
-
// ['resume','<id>'] template over an empty array too.
|
|
146
|
-
if (existing.id === 'codex'
|
|
147
|
-
&& Array.isArray(existing.newSessionIdArgs)
|
|
148
|
-
&& existing.newSessionIdArgs.length === 0
|
|
149
|
-
&& Array.isArray(def.newSessionIdArgs)
|
|
150
|
-
&& def.newSessionIdArgs.length > 0) {
|
|
151
|
-
existing.newSessionIdArgs = def.newSessionIdArgs;
|
|
152
|
-
}
|
|
153
147
|
if (!existing.type) existing.type = def.type;
|
|
154
148
|
} else {
|
|
155
149
|
out.clis.unshift({ ...def });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/sidebar.css
CHANGED
|
@@ -568,11 +568,52 @@ body.is-resizing-sidebar * {
|
|
|
568
568
|
}
|
|
569
569
|
.tree-session.is-running .tree-dot::after {
|
|
570
570
|
background: var(--green);
|
|
571
|
+
/* Soft halo so the dot reads as "alive" even from across the sidebar.
|
|
572
|
+
Box-shadow uses currentColor isn't ideal here (the dot itself uses
|
|
573
|
+
background, not color), so we hardcode each state's halo color
|
|
574
|
+
below. */
|
|
575
|
+
box-shadow: 0 0 0 0 rgba(74, 138, 74, 0.55);
|
|
576
|
+
animation: tree-dot-breathe-idle 2.8s ease-in-out infinite;
|
|
571
577
|
}
|
|
572
578
|
/* Working = CLI is actively writing to its transcript (i.e. thinking
|
|
573
|
-
or printing tokens). Idle stays green; working flips
|
|
579
|
+
or printing tokens). Idle stays green + slow breathe; working flips
|
|
580
|
+
to blue + faster, more obvious breathe. */
|
|
574
581
|
.tree-session.is-running.is-working .tree-dot::after {
|
|
575
582
|
background: var(--blue, #4a73a5);
|
|
583
|
+
box-shadow: 0 0 0 0 rgba(74, 115, 165, 0.65);
|
|
584
|
+
animation: tree-dot-breathe-working 1.4s ease-in-out infinite;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
@keyframes tree-dot-breathe-idle {
|
|
588
|
+
0%, 100% {
|
|
589
|
+
box-shadow: 0 0 0 0 rgba(74, 138, 74, 0.45);
|
|
590
|
+
opacity: 0.85;
|
|
591
|
+
}
|
|
592
|
+
50% {
|
|
593
|
+
box-shadow: 0 0 0 4px rgba(74, 138, 74, 0);
|
|
594
|
+
opacity: 1;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
@keyframes tree-dot-breathe-working {
|
|
598
|
+
0%, 100% {
|
|
599
|
+
box-shadow: 0 0 0 0 rgba(74, 115, 165, 0.65);
|
|
600
|
+
opacity: 0.9;
|
|
601
|
+
transform: scale(1);
|
|
602
|
+
}
|
|
603
|
+
50% {
|
|
604
|
+
box-shadow: 0 0 0 5px rgba(74, 115, 165, 0);
|
|
605
|
+
opacity: 1;
|
|
606
|
+
transform: scale(1.15);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/* Respect users who've asked for less motion — keep the color signal
|
|
610
|
+
but drop the pulse. */
|
|
611
|
+
@media (prefers-reduced-motion: reduce) {
|
|
612
|
+
.tree-session.is-running .tree-dot::after,
|
|
613
|
+
.tree-session.is-running.is-working .tree-dot::after {
|
|
614
|
+
animation: none;
|
|
615
|
+
box-shadow: none;
|
|
616
|
+
}
|
|
576
617
|
}
|
|
577
618
|
.tree-label {
|
|
578
619
|
flex: 1;
|
|
@@ -65,8 +65,16 @@ function UpgradeCard() {
|
|
|
65
65
|
if (!info?.updateAvailable) return;
|
|
66
66
|
setUpgrading(true);
|
|
67
67
|
try {
|
|
68
|
-
await api('POST', '/api/upgrade', { target: 'latest' });
|
|
68
|
+
const r = await api('POST', '/api/upgrade', { target: 'latest' });
|
|
69
69
|
setToast(`upgrading to v${info.latest} · backend will restart`);
|
|
70
|
+
if (r?.closeFrontend) {
|
|
71
|
+
// Backend will respawn with a fresh browser window — close this
|
|
72
|
+
// one so the user isn't stuck on the OfflineBanner during the
|
|
73
|
+
// upgrade window. window.close() only works when the window was
|
|
74
|
+
// script-opened (Edge --app=, our spawned browser); regular tabs
|
|
75
|
+
// ignore it silently, which is fine (OfflineBanner takes over).
|
|
76
|
+
setTimeout(() => { try { window.close(); } catch {} }, 400);
|
|
77
|
+
}
|
|
70
78
|
} catch (e) {
|
|
71
79
|
setUpgrading(false);
|
|
72
80
|
setToast(e.message, 'error');
|
|
@@ -431,8 +431,16 @@ function RestartButton() {
|
|
|
431
431
|
if (!ok) return;
|
|
432
432
|
setBusy(true);
|
|
433
433
|
try {
|
|
434
|
-
await restartBackend();
|
|
434
|
+
const r = await restartBackend();
|
|
435
435
|
setToast('restarting backend…');
|
|
436
|
+
if (r?.closeFrontend) {
|
|
437
|
+
// Backend respawn will pop a fresh browser window — close this
|
|
438
|
+
// one so the user isn't stuck on the OfflineBanner during the
|
|
439
|
+
// ~3s downtime. window.close() only fires in script-opened
|
|
440
|
+
// windows (Edge --app=); regular tabs ignore it and stay open,
|
|
441
|
+
// which is the right behavior for them.
|
|
442
|
+
setTimeout(() => { try { window.close(); } catch {} }, 400);
|
|
443
|
+
}
|
|
436
444
|
} catch (e) {
|
|
437
445
|
setBusy(false);
|
|
438
446
|
setToast(e.message, 'error');
|
|
@@ -62,7 +62,12 @@ function pidAlive(pid) {
|
|
|
62
62
|
|
|
63
63
|
const isWin = process.platform === 'win32';
|
|
64
64
|
const ccsmCmd = isWin ? 'ccsm.cmd' : 'ccsm';
|
|
65
|
-
|
|
65
|
+
// Inherit env but DROP CCSM_NO_BROWSER so the respawned server pops a
|
|
66
|
+
// fresh browser window — the frontend that triggered the restart
|
|
67
|
+
// called window.close() in parallel, and the new window takes its
|
|
68
|
+
// place without the OfflineBanner gap.
|
|
69
|
+
const childEnv = { ...process.env };
|
|
70
|
+
delete childEnv.CCSM_NO_BROWSER;
|
|
66
71
|
let exe, exeArgs;
|
|
67
72
|
if (isWin) {
|
|
68
73
|
exe = process.env.ComSpec || 'cmd.exe';
|
|
@@ -126,7 +126,12 @@ function pidAlive(pid) {
|
|
|
126
126
|
const ccsmCmd = installPrefix
|
|
127
127
|
? (isWin ? path.join(installPrefix, 'ccsm.cmd') : path.join(installPrefix, 'bin', 'ccsm'))
|
|
128
128
|
: (isWin ? 'ccsm.cmd' : 'ccsm');
|
|
129
|
-
|
|
129
|
+
// Drop CCSM_NO_BROWSER so the post-upgrade server pops a fresh window
|
|
130
|
+
// — the frontend that triggered the upgrade window.close()'d in
|
|
131
|
+
// parallel, and the new window takes its place. Skips the
|
|
132
|
+
// OfflineBanner gap that used to bridge the upgrade.
|
|
133
|
+
const childEnv = { ...process.env };
|
|
134
|
+
delete childEnv.CCSM_NO_BROWSER;
|
|
130
135
|
let exe, exeArgs;
|
|
131
136
|
if (isWin) {
|
|
132
137
|
exe = process.env.ComSpec || 'cmd.exe';
|
package/server.js
CHANGED
|
@@ -221,7 +221,10 @@ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
|
|
|
221
221
|
cwd,
|
|
222
222
|
env,
|
|
223
223
|
meta: { ...meta, cliId: cli.id, cliName: cli.name },
|
|
224
|
-
onData: () => {
|
|
224
|
+
onData: () => {
|
|
225
|
+
persistedSessions.touch(sessionId).catch(() => {});
|
|
226
|
+
try { require('./lib/cliActivity').noteOutput(sessionId); } catch {}
|
|
227
|
+
},
|
|
225
228
|
onExit: ({ exitCode }) => {
|
|
226
229
|
persistedSessions.markExited(sessionId, exitCode).catch(() => {});
|
|
227
230
|
},
|
|
@@ -926,7 +929,7 @@ app.post('/api/restart', asyncH(async (_req, res) => {
|
|
|
926
929
|
restartInFlight = true;
|
|
927
930
|
|
|
928
931
|
if (process.env.CCSM_DEV === '1') {
|
|
929
|
-
res.json({ ok: true, started: true, mode: 'dev' });
|
|
932
|
+
res.json({ ok: true, started: true, mode: 'dev', closeFrontend: false });
|
|
930
933
|
setImmediate(() => gracefulShutdown('restart (dev)'));
|
|
931
934
|
return;
|
|
932
935
|
}
|
|
@@ -941,7 +944,11 @@ app.post('/api/restart', asyncH(async (_req, res) => {
|
|
|
941
944
|
return res.status(500).json({ error: `helper copy failed: ${e.message}` });
|
|
942
945
|
}
|
|
943
946
|
const args = [helperTmp, String(currentPort), String(process.pid)];
|
|
944
|
-
|
|
947
|
+
// closeFrontend asks the calling tab to window.close() itself — the
|
|
948
|
+
// helper will respawn ccsm WITHOUT CCSM_NO_BROWSER, so a fresh window
|
|
949
|
+
// pops up once the new backend is listening. Net effect: the user
|
|
950
|
+
// never sees the OfflineBanner during a restart.
|
|
951
|
+
res.json({ ok: true, started: true, helper: helperTmp, closeFrontend: true });
|
|
945
952
|
|
|
946
953
|
setImmediate(() => {
|
|
947
954
|
const { spawn } = require('node:child_process');
|
|
@@ -1081,7 +1088,7 @@ app.post('/api/upgrade', asyncH(async (req, res) => {
|
|
|
1081
1088
|
}
|
|
1082
1089
|
const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn];
|
|
1083
1090
|
|
|
1084
|
-
res.json({ ok: true, started: true, target, helper: helperTmp });
|
|
1091
|
+
res.json({ ok: true, started: true, target, helper: helperTmp, closeFrontend: true });
|
|
1085
1092
|
|
|
1086
1093
|
// Flush response, then spawn helper detached and gracefulShutdown so
|
|
1087
1094
|
// the helper's npm install isn't fighting our open file handles.
|