@bakapiano/ccsm 0.17.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.17.0",
3
+ "version": "0.17.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",
@@ -302,82 +302,20 @@
302
302
  50% { box-shadow: 0 0 0 4px rgba(26, 24, 21, 0); }
303
303
  }
304
304
 
305
- /* ── Health overlay · full-screen modal while backend is down ─────── */
306
- .health-overlay {
307
- position: fixed;
308
- inset: 0;
309
- z-index: 9999;
310
- background: rgba(26, 24, 21, 0.48);
311
- backdrop-filter: blur(4px);
312
- -webkit-backdrop-filter: blur(4px);
313
- display: flex;
314
- align-items: center;
315
- justify-content: center;
316
- animation: health-fade-in 0.2s ease-out;
317
- }
318
- @keyframes health-fade-in {
319
- from { opacity: 0; }
320
- to { opacity: 1; }
321
- }
322
- .health-card {
323
- background: var(--bg-elev);
324
- border: 1px solid var(--border);
325
- border-radius: 14px;
326
- padding: 36px 44px;
327
- max-width: 420px;
328
- text-align: center;
329
- box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
330
- font-family: var(--body);
331
- }
305
+ /* HealthOverlay reuses .offline-overlay / .offline-card above. The
306
+ only extra is the spinner shown during the "Checking…" phase — sized
307
+ to fill the .offline-brand slot in place of the BrandMark logo. */
332
308
  .health-spinner {
333
- width: 22px;
334
- height: 22px;
335
- border: 2px solid var(--border);
336
- border-top-color: var(--accent, #4a73a5);
309
+ width: 36px;
310
+ height: 36px;
311
+ border: 3px solid var(--border);
312
+ border-top-color: var(--ink);
337
313
  border-radius: 50%;
338
- margin: 0 auto 14px;
339
314
  animation: health-spin 0.8s linear infinite;
340
315
  }
341
316
  @keyframes health-spin {
342
317
  to { transform: rotate(360deg); }
343
318
  }
344
- .health-dot {
345
- width: 12px;
346
- height: 12px;
347
- border-radius: 50%;
348
- background: var(--red, #b73f3f);
349
- margin: 0 auto 14px;
350
- }
351
- .health-title {
352
- font-size: 15px;
353
- font-weight: 500;
354
- letter-spacing: -0.005em;
355
- color: var(--ink);
356
- margin-bottom: 6px;
357
- }
358
- .health-meta {
359
- font-size: 12.5px;
360
- color: var(--ink-mid);
361
- line-height: 1.4;
362
- margin-bottom: 14px;
363
- }
364
- .health-start {
365
- display: inline-block;
366
- margin-top: 4px;
367
- text-decoration: none;
368
- }
369
- .health-hint {
370
- font-size: 11.5px;
371
- color: var(--ink-muted);
372
- margin-top: 14px;
373
- }
374
- .health-hint code {
375
- font-family: var(--mono);
376
- background: var(--bg);
377
- padding: 1px 5px;
378
- border-radius: 3px;
379
- border: 1px solid var(--border);
380
- }
381
319
 
382
320
  /* ── Keybinding recorder modal ───────────────────────────────────── */
383
321
  .kbd-recorder-overlay {
@@ -3,7 +3,6 @@ import { activeTab } from '../state.js';
3
3
  import { Sidebar } from './Sidebar.js';
4
4
  import { Toast } from './Toast.js';
5
5
  import { DialogHost } from './DialogHost.js';
6
- import { OfflineBanner } from './OfflineBanner.js';
7
6
  import { HealthOverlay } from './HealthOverlay.js';
8
7
  import { SessionsPage } from '../pages/SessionsPage.js';
9
8
  import { LaunchPage } from '../pages/LaunchPage.js';
@@ -29,7 +28,6 @@ export function App() {
29
28
  ${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
30
29
  </div>
31
30
  </main>
32
- <${OfflineBanner} />
33
31
  <${Toast} />
34
32
  <${DialogHost} />
35
33
  <${HealthOverlay} />
@@ -26,7 +26,19 @@ export function EntityFormModal({
26
26
  const [testing, setTesting] = useState(false);
27
27
  const [testResult, setTestResult] = useState(null);
28
28
 
29
- const isReadOnly = (key) => readOnlyKeys.includes(key);
29
+ // A field is read-only if its key is in the static `readOnlyKeys`
30
+ // prop OR its own `readOnly` predicate (called with the current
31
+ // draft) returns true. The predicate lets a field react to other
32
+ // fields' values — e.g. lock newSessionIdArgs once a known `type`
33
+ // is picked, since those args are an integration contract with the
34
+ // upstream CLI, not a user knob.
35
+ const isReadOnly = (field) => {
36
+ if (readOnlyKeys.includes(field.key)) return true;
37
+ if (typeof field.readOnly === 'function') {
38
+ try { return !!field.readOnly(draft); } catch { return false; }
39
+ }
40
+ return !!field.readOnly;
41
+ };
30
42
 
31
43
  const submit = async (ev) => {
32
44
  ev?.preventDefault?.();
@@ -61,7 +73,7 @@ export function EntityFormModal({
61
73
  <span class="entity-field-label">${f.label}</span>
62
74
  ${f.type === 'select' ? html`
63
75
  <select class="input" value=${draft[f.key] || ''}
64
- disabled=${isReadOnly(f.key)}
76
+ disabled=${isReadOnly(f)}
65
77
  onChange=${(e) => {
66
78
  const next = { ...draft, [f.key]: e.target.value };
67
79
  const sideEffects = f.onChange?.(e.target.value, next);
@@ -71,13 +83,13 @@ export function EntityFormModal({
71
83
  <option value=${opt.value}>${opt.label}</option>`)}
72
84
  </select>
73
85
  ` : f.type === 'iconRadio' ? html`
74
- <div class=${`icon-radio${isReadOnly(f.key) ? ' is-disabled' : ''}`}>
86
+ <div class=${`icon-radio${isReadOnly(f) ? ' is-disabled' : ''}`}>
75
87
  ${(f.options || []).map((opt) => html`
76
88
  <button type="button" key=${opt.value}
77
89
  class=${`icon-radio-opt${draft[f.key] === opt.value ? ' is-active' : ''}`}
78
- disabled=${isReadOnly(f.key)}
90
+ disabled=${isReadOnly(f)}
79
91
  onClick=${() => {
80
- if (isReadOnly(f.key)) return;
92
+ if (isReadOnly(f)) return;
81
93
  const next = { ...draft, [f.key]: opt.value };
82
94
  const sideEffects = f.onChange?.(opt.value, next);
83
95
  setDraft(sideEffects ? { ...next, ...sideEffects } : next);
@@ -89,20 +101,20 @@ export function EntityFormModal({
89
101
  ` : f.type === 'checkbox' ? html`
90
102
  <span class="entity-checkbox-row">
91
103
  <input type="checkbox" checked=${!!draft[f.key]}
92
- disabled=${isReadOnly(f.key)}
104
+ disabled=${isReadOnly(f)}
93
105
  onChange=${(e) => setDraft({ ...draft, [f.key]: e.target.checked })} />
94
- ${f.hint ? html`<span class="entity-field-hint">${f.hint}</span>` : null}
106
+ ${f.hint ? html`<span class="entity-field-hint">${typeof f.hint === 'function' ? f.hint(draft) : f.hint}</span>` : null}
95
107
  </span>
96
108
  ` : html`
97
109
  <input type=${f.type || 'text'}
98
110
  class=${`input${f.mono ? ' mono' : ''}`}
99
111
  placeholder=${f.placeholder || ''}
100
112
  value=${draft[f.key] || ''}
101
- readonly=${isReadOnly(f.key)}
113
+ readonly=${isReadOnly(f)}
102
114
  onInput=${(e) => setDraft({ ...draft, [f.key]: e.target.value })}
103
- autoFocus=${f.autoFocus && !isReadOnly(f.key)} />`}
115
+ autoFocus=${f.autoFocus && !isReadOnly(f)} />`}
104
116
  ${f.hint && f.type !== 'checkbox' ? html`
105
- <span class="entity-field-hint">${f.hint}</span>` : null}
117
+ <span class="entity-field-hint">${typeof f.hint === 'function' ? f.hint(draft) : f.hint}</span>` : null}
106
118
  </label>`)}
107
119
  ${testResult ? html`
108
120
  <div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
@@ -20,6 +20,7 @@ import { html } from '../html.js';
20
20
  import { useEffect } from 'preact/hooks';
21
21
  import { serverHealth, hasBootedOnline } from '../state.js';
22
22
  import { pollHealth, refreshAll } from '../api.js';
23
+ import { BrandMark } from '../icons.js';
23
24
 
24
25
  const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
25
26
  const FAST_POLL_MS = 1500;
@@ -28,11 +29,6 @@ export function HealthOverlay() {
28
29
  const h = serverHealth.value;
29
30
  const offline = h.state === 'offline';
30
31
  const count = h.failureCount || 0;
31
-
32
- // Don't render the overlay during the very first connect attempt
33
- // (before we've ever been online) — main.js shows nothing prominent
34
- // there anyway, and the modal flashing on every page load is
35
- // annoying. Only show after we've seen the backend at least once.
36
32
  const everSeen = hasBootedOnline.value;
37
33
 
38
34
  useEffect(() => {
@@ -41,9 +37,6 @@ export function HealthOverlay() {
41
37
  return () => clearInterval(id);
42
38
  }, [offline]);
43
39
 
44
- // When the backend comes back online after we've shown the overlay,
45
- // refresh all derived state once — sessions/folders/workspaces may
46
- // have changed during the outage (post-restart, post-upgrade).
47
40
  useEffect(() => {
48
41
  if (!offline && everSeen) {
49
42
  refreshAll().catch(() => {});
@@ -54,27 +47,44 @@ export function HealthOverlay() {
54
47
 
55
48
  const showStart = count >= THRESHOLD;
56
49
 
50
+ // Reuses the .offline-overlay / .offline-card classes so the card
51
+ // layout (brand mark, big title, copy, primary action button,
52
+ // collapsible npm-install fallback) matches what the OfflineBanner
53
+ // used to render. HealthOverlay differs only in the two states:
54
+ // early polls show a spinner + "Checking…" instead of the static
55
+ // "Backend not running" card.
57
56
  return html`
58
- <div class="health-overlay" role="dialog" aria-modal="true" aria-live="polite">
59
- <div class="health-card">
57
+ <div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
58
+ <div class="offline-card">
59
+ <div class="offline-brand">${
60
+ showStart
61
+ ? html`<${BrandMark} />`
62
+ : html`<div class="health-spinner" aria-hidden="true"></div>`
63
+ }</div>
60
64
  ${!showStart ? html`
61
- <div class="health-spinner" aria-hidden="true"></div>
62
- <div class="health-title">Checking backend health…</div>
63
- <div class="health-meta">
64
- ${count === 0 ? 'Connecting…' : `${count} attempt${count > 1 ? 's' : ''}`}
65
- </div>
65
+ <h1 class="offline-title">Checking backend health…</h1>
66
+ <p class="offline-copy">
67
+ ${count === 0 ? 'Probing localhost:7777.' : `${count} attempt${count > 1 ? 's' : ''}. Hang tight.`}
68
+ </p>
66
69
  ` : html`
67
- <div class="health-dot" aria-hidden="true"></div>
68
- <div class="health-title">Backend not running</div>
69
- <div class="health-meta">
70
- ${count} failed pings. Wake the backend manually below we won't auto-restart.
71
- </div>
72
- <a class="action primary health-start" href="ccsm://start">
73
- Start backend
74
- </a>
75
- <div class="health-hint">
76
- Or run <code>ccsm</code> in a terminal.
70
+ <h1 class="offline-title">Backend not running</h1>
71
+ <p class="offline-copy">
72
+ ccsm's local backend isn't reachable. Wake it manually below — we won't
73
+ auto-restart. Windows may ask once for permission; tick <em>Always allow</em>
74
+ to silence future prompts.
75
+ </p>
76
+ <div class="offline-actions">
77
+ <a class="action primary big" href="ccsm://start">Start backend</a>
77
78
  </div>
79
+ <details class="offline-fallback">
80
+ <summary>Don't have ccsm installed?</summary>
81
+ <div class="offline-fallback-body">
82
+ <p>Install once via npm, then come back here:</p>
83
+ <pre><code>npm i -g @bakapiano/ccsm</code></pre>
84
+ <p>Or run a one-shot trial without installing:</p>
85
+ <pre><code>npx @bakapiano/ccsm</code></pre>
86
+ </div>
87
+ </details>
78
88
  `}
79
89
  </div>
80
90
  </div>`;
@@ -42,30 +42,51 @@ function cliFieldsFor({ creating } = {}) {
42
42
  { value: 'copilot', label: 'GitHub Copilot', icon: html`<${IconCopilotColor} />` },
43
43
  { value: 'other', label: 'Other', icon: html`<${IconTerminal} />` },
44
44
  ],
45
- // When user picks a type while creating, prefill command + resumeArgs.
46
- // For edit mode we don't override what the user already has.
47
- onChange: creating ? (v, next) => {
45
+ // Type-change side effects. For known types we force the
46
+ // integration args (newSessionIdArgs / resumeIdArgs) to the
47
+ // canonical template those fields are locked anyway so
48
+ // there's no value in leaving stale strings around. For
49
+ // type='other' we leave existing args alone so the user can
50
+ // keep editing them. Name + command are only prefilled when
51
+ // creating (don't clobber a saved CLI's name on edit).
52
+ onChange: (v, next) => {
48
53
  const d = CLI_TYPE_DEFAULTS[v];
49
54
  if (!d) return null;
50
- const patch = { resumeIdArgs: d.resumeIdArgs, newSessionIdArgs: d.newSessionIdArgs };
51
- if (!next.command || !next.command.trim()) patch.command = d.command || '';
52
- if (!next.name || !next.name.trim()) {
53
- patch.name = v === 'claude' ? 'Claude Code'
54
- : v === 'codex' ? 'OpenAI Codex'
55
- : v === 'copilot' ? 'GitHub Copilot'
56
- : '';
55
+ const patch = {};
56
+ if (v !== 'other') {
57
+ patch.resumeIdArgs = d.resumeIdArgs;
58
+ patch.newSessionIdArgs = d.newSessionIdArgs;
59
+ }
60
+ if (creating) {
61
+ if (!next.command || !next.command.trim()) patch.command = d.command || '';
62
+ if (!next.name || !next.name.trim()) {
63
+ patch.name = v === 'claude' ? 'Claude Code'
64
+ : v === 'codex' ? 'OpenAI Codex'
65
+ : v === 'copilot' ? 'GitHub Copilot'
66
+ : '';
67
+ }
57
68
  }
58
69
  return patch;
59
- } : undefined,
70
+ },
60
71
  },
61
72
  { key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
62
73
  { key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
63
74
  { key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '',
64
75
  hint: 'Used on every launch.' },
65
76
  { key: 'newSessionIdArgs', label: 'New session id args', mono: true, placeholder: '--session-id <id>',
66
- hint: 'ccsm pre-generates a UUID and substitutes it for <id> on first launch the upstream CLI session id is known immediately.' },
77
+ // Lock for known typesthose args are an integration contract
78
+ // with the upstream CLI, not a user knob. Only Type=Other allows
79
+ // a custom value (for hand-rolled CLIs ccsm doesn't ship a
80
+ // template for).
81
+ readOnly: (d) => d.type && d.type !== 'other',
82
+ hint: (d) => d.type && d.type !== 'other'
83
+ ? `Locked to the canonical flags for ${d.type}. Change Type to "Other" to override.`
84
+ : 'ccsm pre-generates a UUID and substitutes it for <id> on first launch — the upstream CLI session id is known immediately.' },
67
85
  { key: 'resumeIdArgs', label: 'Resume by id args', mono: true, placeholder: '--resume <id>',
68
- hint: 'Used on every resume. Substitutes <id> with the captured session UUID.' },
86
+ readOnly: (d) => d.type && d.type !== 'other',
87
+ hint: (d) => d.type && d.type !== 'other'
88
+ ? `Locked to the canonical flags for ${d.type}. Change Type to "Other" to override.`
89
+ : 'Used on every resume. Substitutes <id> with the captured session UUID.' },
69
90
  { key: 'shell', label: 'Shell', type: 'select', default: 'direct', options: [
70
91
  { value: 'direct', label: 'direct (real .exe / .cmd)' },
71
92
  { value: 'pwsh', label: 'pwsh (PowerShell aliases & functions)' },
package/scripts/dev.js CHANGED
@@ -38,6 +38,52 @@ if (!fs.existsSync(configPath)) {
38
38
  }, null, 2));
39
39
  }
40
40
 
41
+ // Mirror pages-root assets into public/ so the dev server can serve
42
+ // them at the URL paths the deployed site uses (manifest at
43
+ // /manifest.webmanifest, setup page at /setup/). Both mirror files
44
+ // are .gitignored — they exist only for local preview.
45
+ //
46
+ // The manifest is rewritten with a "ccsm-dev" identity so the PWA
47
+ // installed from dev shows up separately in Chrome's installed-apps
48
+ // list and Start Menu, instead of conflicting with the prod CCSM
49
+ // install.
50
+ const REPO_ROOT = path.join(__dirname, '..');
51
+ const PAGES_ROOT = path.join(REPO_ROOT, 'pages-root');
52
+ const PUBLIC_DIR = path.join(REPO_ROOT, 'public');
53
+
54
+ function mirrorSetup() {
55
+ try {
56
+ const src = path.join(PAGES_ROOT, 'setup');
57
+ const dst = path.join(PUBLIC_DIR, 'setup');
58
+ fs.mkdirSync(dst, { recursive: true });
59
+ for (const f of fs.readdirSync(src)) {
60
+ fs.copyFileSync(path.join(src, f), path.join(dst, f));
61
+ }
62
+ } catch (e) { console.warn('[dev] setup mirror failed:', e.message); }
63
+ }
64
+ function writeDevManifest() {
65
+ try {
66
+ const src = path.join(PAGES_ROOT, 'manifest.webmanifest');
67
+ const m = JSON.parse(fs.readFileSync(src, 'utf8'));
68
+ m.id = '/?ccsm-dev';
69
+ m.name = 'CCSM dev';
70
+ m.short_name = 'CCSM dev';
71
+ // Dev runs at host root (localhost:7788/), so scope + start_url
72
+ // anchor at `/` not `./` (which would resolve relative to the
73
+ // manifest URL — same result here, but explicit is clearer).
74
+ m.scope = '/';
75
+ m.start_url = '/';
76
+ // Drop related_applications self-reference — its URL points at
77
+ // the prod GH Pages manifest, not this dev one. Leaving it in
78
+ // would let prod's getInstalledRelatedApps() detect dev installs
79
+ // as if they were prod, which is the opposite of what we want.
80
+ delete m.related_applications;
81
+ fs.writeFileSync(path.join(PUBLIC_DIR, 'manifest.webmanifest'), JSON.stringify(m, null, 2));
82
+ } catch (e) { console.warn('[dev] manifest mirror failed:', e.message); }
83
+ }
84
+ mirrorSetup();
85
+ writeDevManifest();
86
+
41
87
  const env = {
42
88
  ...process.env,
43
89
  CCSM_HOME: DEV_HOME,
@@ -130,29 +130,24 @@ try {
130
130
  warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
131
131
  }
132
132
 
133
- // Auto-launch ccsm after install so the user lands directly in the app
134
- // without needing a second command. Detached + windowsHide so the npm
135
- // install command returns immediately. Skip if CCSM_NO_AUTOLAUNCH=1 is
136
- // set (CI, headless setups).
133
+ // Open the hosted setup guide. The page walks the user through the
134
+ // remaining one-time setup (allow ccsm:// protocol, firewall, install
135
+ // as PWA) and Step 1's "Try ccsm://start" button doubles as ccsm
136
+ // auto-launch so we don't need a separate spawn here. Set
137
+ // CCSM_NO_AUTOLAUNCH=1 to skip (CI, headless setups).
137
138
  if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
138
139
  try {
139
- const { spawn } = require('node:child_process');
140
- // Spawn `node bin/ccsm.js` directly NOT ccsm.cmd. On Windows,
141
- // child_process.spawn() with shell:false refuses .cmd files (throws
142
- // EINVAL); using shell:true would flash a console window. Going
143
- // through node + the JS entrypoint sidesteps both problems and
144
- // matches exactly what the .cmd shim would have invoked.
145
- const launcherJs = path.join(__dirname, '..', 'bin', 'ccsm.js');
146
- const child = spawn(process.execPath, [launcherJs], {
147
- detached: true,
148
- stdio: 'ignore',
149
- windowsHide: true,
150
- });
151
- child.unref();
152
- log('launching ccsm now · check for the chromeless window');
140
+ // `start` on Windows opens the default browser without attaching a
141
+ // console. Run via cmd.exe /c since `start` is a cmd builtin.
142
+ require('node:child_process').spawn(
143
+ 'cmd.exe',
144
+ ['/d', '/s', '/c', 'start', '', 'https://bakapiano.github.io/ccsm/setup/'],
145
+ { detached: true, stdio: 'ignore', windowsHide: true }
146
+ ).unref();
147
+ log('opened setup guide · https://bakapiano.github.io/ccsm/setup/');
153
148
  log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
154
149
  } catch (e) {
155
- warn(`auto-launch failed · ${e.message}`);
150
+ warn(`setup guide open failed · ${e.message}`);
156
151
  warn('run `ccsm` manually to start.');
157
152
  }
158
153
  }