@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 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.
@@ -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 keeps old configs aligned with new
134
- // schema additions (resumeIdArgs, type, etc.) without clobbering
135
- // the user's customisations.
136
- if (existing.resumeIdArgs == null) existing.resumeIdArgs = def.resumeIdArgs;
137
- if (existing.newSessionIdArgs == null) existing.newSessionIdArgs = def.newSessionIdArgs;
138
- // Drop the v0.x `resumeArgs` fallback all builtins now have
139
- // pre-assigned UUIDs (claude/copilot via flag, codex via seed) so
140
- // resumeIdArgs always applies on resume.
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.0",
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",
@@ -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 to blue. */
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
- const childEnv = { ...process.env, CCSM_NO_BROWSER: '1' };
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
- const childEnv = { ...process.env, CCSM_NO_BROWSER: '1' };
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: () => { persistedSessions.touch(sessionId).catch(() => {}); },
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
- res.json({ ok: true, started: true, helper: helperTmp });
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.