@bakapiano/ccsm 0.12.0 → 0.13.0

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.
@@ -84,6 +84,12 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
84
84
  if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
85
85
  });
86
86
  proc.onExit(({ exitCode, signal }) => {
87
+ // If a respawn replaced us in the pool (same entryId, new entry
88
+ // object), do not touch persistedSessions or schedule a delete —
89
+ // those belong to the new entry now. Without this guard, a slow-
90
+ // dying old PTY would fire markExited on the same sessionId and
91
+ // clobber the new spawn's markRunning state.
92
+ if (sessions.get(entryId) !== entry) return;
87
93
  entry.exitCode = exitCode;
88
94
  entry.exitedAt = Date.now();
89
95
  const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
@@ -91,8 +97,18 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
91
97
  try { ws.send(frame); } catch {}
92
98
  }
93
99
  if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
94
- setTimeout(() => sessions.delete(entryId), 30_000);
100
+ setTimeout(() => {
101
+ if (sessions.get(entryId) === entry) sessions.delete(entryId);
102
+ }, 30_000);
95
103
  });
104
+ // If a previous entry exists under the same id (respawn), kill its
105
+ // pty so we don't have zombie claude.exe processes hanging on. The
106
+ // onExit guard above ensures its callback no-ops once we've taken
107
+ // over the slot.
108
+ const prev = sessions.get(entryId);
109
+ if (prev && !prev.exitedAt) {
110
+ try { prev.pty.kill(); } catch {}
111
+ }
96
112
  sessions.set(entryId, entry);
97
113
  return entry;
98
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
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",
@@ -980,6 +980,38 @@
980
980
  gap: 6px;
981
981
  margin-top: 4px;
982
982
  }
983
+ .entity-test-button { margin-right: auto; }
984
+
985
+ .entity-test-result {
986
+ margin-top: 4px;
987
+ padding: 8px 10px;
988
+ border-radius: 6px;
989
+ border: 1px solid var(--border);
990
+ background: var(--bg);
991
+ display: flex;
992
+ flex-direction: column;
993
+ gap: 6px;
994
+ font-size: 12.5px;
995
+ }
996
+ .entity-test-result.is-ok { border-color: rgba(74, 138, 74, 0.4); background: rgba(74, 138, 74, 0.06); }
997
+ .entity-test-result.is-fail { border-color: rgba(183, 63, 63, 0.4); background: rgba(183, 63, 63, 0.06); }
998
+ .entity-test-summary {
999
+ font-family: var(--body);
1000
+ font-weight: 500;
1001
+ color: var(--ink);
1002
+ }
1003
+ .entity-test-result.is-fail .entity-test-summary { color: var(--red); }
1004
+ .entity-test-out {
1005
+ margin: 0;
1006
+ font-family: var(--mono);
1007
+ font-size: 11.5px;
1008
+ color: var(--ink-mid);
1009
+ white-space: pre-wrap;
1010
+ word-break: break-word;
1011
+ max-height: 120px;
1012
+ overflow-y: auto;
1013
+ }
1014
+ .entity-test-out.is-stderr { color: var(--ink-muted); border-top: 1px dashed var(--border); padding-top: 4px; }
983
1015
 
984
1016
  .icon-radio {
985
1017
  display: grid;
package/public/js/api.js CHANGED
@@ -28,11 +28,11 @@ export async function loadConfig() {
28
28
  export async function updateCli(id, patch) {
29
29
  const cfg = S.config.value || (await api('GET', '/api/config'));
30
30
  const target = (cfg.clis || []).find((c) => c.id === id);
31
- // Built-in CLIs lock down identity-defining fields. UI already greys these
32
- // out; we belt-and-braces here so a tampered request from elsewhere
33
- // can't change them either.
31
+ // Built-in CLIs lock down structural fields (id + builtin flag) but
32
+ // allow command edits users routinely need to point at an absolute
33
+ // path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
34
+ // when the bare name isn't on the spawn-time PATH.
34
35
  if (target?.builtin) {
35
- delete patch.command;
36
36
  delete patch.id;
37
37
  delete patch.builtin;
38
38
  }
@@ -52,6 +52,14 @@ export async function updateCli(id, patch) {
52
52
  return id;
53
53
  }
54
54
 
55
+ // Probe a (possibly-unsaved) CLI config: spawn its command with
56
+ // `--version`, capture output, see if it looks like the claimed type.
57
+ // `args` is intentionally ignored server-side — runtime flags can
58
+ // disturb a quick probe.
59
+ export async function testCli({ command, shell, type }) {
60
+ return api('POST', '/api/clis/test', { command, shell, type });
61
+ }
62
+
55
63
  export async function deleteCli(id) {
56
64
  const cfg = S.config.value || (await api('GET', '/api/config'));
57
65
  const target = (cfg.clis || []).find((c) => c.id === id);
@@ -18,10 +18,13 @@ import { Modal } from './Modal.js';
18
18
  export function EntityFormModal({
19
19
  title, fields, initial = {}, submitLabel = 'Save',
20
20
  readOnlyKeys = [],
21
- onSubmit, onClose, danger,
21
+ onSubmit, onClose, onTest, testLabel = 'Test',
22
+ danger,
22
23
  }) {
23
24
  const [draft, setDraft] = useState(() => ({ ...initialFrom(fields), ...initial }));
24
25
  const [saving, setSaving] = useState(false);
26
+ const [testing, setTesting] = useState(false);
27
+ const [testResult, setTestResult] = useState(null);
25
28
 
26
29
  const isReadOnly = (key) => readOnlyKeys.includes(key);
27
30
 
@@ -36,6 +39,20 @@ export function EntityFormModal({
36
39
  finally { setSaving(false); }
37
40
  };
38
41
 
42
+ const runTest = async () => {
43
+ if (!onTest) return;
44
+ setTesting(true);
45
+ setTestResult(null);
46
+ try {
47
+ const r = await onTest(draft);
48
+ setTestResult(r);
49
+ } catch (e) {
50
+ setTestResult({ ok: false, spawnError: String(e?.message || e) });
51
+ } finally {
52
+ setTesting(false);
53
+ }
54
+ };
55
+
39
56
  return html`
40
57
  <${Modal} title=${title} onClose=${onClose} width=${440}>
41
58
  <form class="entity-form" onSubmit=${submit}>
@@ -87,7 +104,26 @@ export function EntityFormModal({
87
104
  ${f.hint && f.type !== 'checkbox' ? html`
88
105
  <span class="entity-field-hint">${f.hint}</span>` : null}
89
106
  </label>`)}
107
+ ${testResult ? html`
108
+ <div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
109
+ <div class="entity-test-summary">
110
+ ${testResult.ok ? '✓' : '✗'} ${testResult.ok ? 'works' : 'failed'}
111
+ ${typeof testResult.exitCode === 'number' ? html` · exit ${testResult.exitCode}` : null}
112
+ ${typeof testResult.durationMs === 'number' ? html` · ${testResult.durationMs}ms` : null}
113
+ ${testResult.timedOut ? html` · timed out` : null}
114
+ ${testResult.matchedType === true ? html` · type matches ${testResult.expectedType}` : null}
115
+ ${testResult.matchedType === false ? html` · type mismatch (expected ${testResult.expectedType})` : null}
116
+ </div>
117
+ ${testResult.spawnError ? html`<pre class="entity-test-out">${testResult.spawnError}</pre>` : null}
118
+ ${testResult.stdout ? html`<pre class="entity-test-out">${testResult.stdout}</pre>` : null}
119
+ ${testResult.stderr ? html`<pre class="entity-test-out is-stderr">${testResult.stderr}</pre>` : null}
120
+ </div>` : null}
90
121
  <div class="entity-form-actions">
122
+ ${onTest ? html`
123
+ <button type="button" class="action small subtle entity-test-button"
124
+ disabled=${testing} onClick=${runTest}>
125
+ ${testing ? 'Testing…' : testLabel}
126
+ </button>` : null}
91
127
  <button type="button" class="action small subtle" onClick=${onClose}>Cancel</button>
92
128
  <button type="submit" class=${`action small ${danger ? 'danger' : 'primary'}`}
93
129
  disabled=${saving}>
@@ -56,47 +56,6 @@ function SessionRow({ s }) {
56
56
  }
57
57
  };
58
58
 
59
- const onContext = async (ev) => {
60
- ev.preventDefault();
61
- ev.stopPropagation();
62
- // Quick menu: Rename / Move / Delete. We use sequential prompts
63
- // to avoid building a real context-menu component for now.
64
- const action = await ccsmPrompt(
65
- `${title} · ${running ? 'running' : 'stopped'}\nType: rename / move / delete / resume / cancel`,
66
- 'cancel', { title: s.id, okLabel: 'OK' });
67
- if (!action) return;
68
- const verb = action.trim().toLowerCase();
69
- if (verb === 'rename') {
70
- const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
71
- if (next === null) return;
72
- try { await setSessionTitle(s.id, next.trim()); setToast('renamed'); }
73
- catch (e) { setToast(e.message, 'error'); }
74
- } else if (verb === 'move') {
75
- // Move to a folder by name
76
- const folderNames = folders.value.map((f) => f.name).join(', ');
77
- const target = await ccsmPrompt(
78
- `Move to which folder? (empty = Unsorted)\nExisting: ${folderNames || '(none)'}`,
79
- '', { title: 'Move', okLabel: 'Move' });
80
- if (target === null) return;
81
- const t = target.trim();
82
- const folder = t ? folders.value.find((f) => f.name.toLowerCase() === t.toLowerCase()) : null;
83
- if (t && !folder) { setToast(`no folder named "${t}"`, 'error'); return; }
84
- try { await setSessionFolder(s.id, folder ? folder.id : null); setToast('moved'); }
85
- catch (e) { setToast(e.message, 'error'); }
86
- } else if (verb === 'delete') {
87
- const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
88
- title: 'Delete session', okLabel: 'Delete', danger: true });
89
- if (!ok) return;
90
- try {
91
- await deleteSession(s.id);
92
- if (activeSessionId.value === s.id) activeSessionId.value = null;
93
- } catch (e) { setToast(e.message, 'error'); }
94
- } else if (verb === 'resume' && !running) {
95
- try { await resumeSession(s.id); selectSession(s.id); }
96
- catch (e) { setToast(e.message, 'error'); }
97
- }
98
- };
99
-
100
59
  const onRenameClick = async (ev) => {
101
60
  ev.preventDefault();
102
61
  ev.stopPropagation();
@@ -135,7 +94,6 @@ function SessionRow({ s }) {
135
94
  onDragStart=${onDragStart}
136
95
  onDragEnd=${onDragEnd}
137
96
  onClick=${onClick}
138
- onContextMenu=${onContext}
139
97
  title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
140
98
  <span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
141
99
  <span class="tree-label">${title}</span>
@@ -86,6 +86,22 @@ export function TerminalView({ terminalId }) {
86
86
  } catch (e) {
87
87
  console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
88
88
  }
89
+ // Ctrl+C with a selection: by default xterm.js sends \x03 AND the
90
+ // browser's own copy event fires — so the user gets "selection
91
+ // copied to clipboard" AND the running CLI gets SIGINT. Mirror
92
+ // VSCode/Windows Terminal behaviour: when there's a selection,
93
+ // suppress \x03 and let the copy event do its thing. With no
94
+ // selection, Ctrl+C still sends \x03 normally.
95
+ term.attachCustomKeyEventHandler((ev) => {
96
+ if (ev.type === 'keydown'
97
+ && ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
98
+ && ev.key.toLowerCase() === 'c'
99
+ && term.hasSelection()) {
100
+ return false;
101
+ }
102
+ return true;
103
+ });
104
+
89
105
  const host = hostRef.current;
90
106
  term.open(host);
91
107
  // Defer fit one tick so the container has measured layout
@@ -10,7 +10,7 @@ import {
10
10
  } from '../state.js';
11
11
  import {
12
12
  api, loadConfig, loadWorkspaces, loadFolders,
13
- createCli, updateCli, deleteCli, setDefaultCli,
13
+ createCli, updateCli, deleteCli, setDefaultCli, testCli,
14
14
  createRepo, updateRepo, deleteRepo,
15
15
  createFolder, renameFolder, deleteFolder, reorderFolders,
16
16
  deleteWorkspace,
@@ -252,6 +252,7 @@ export function ConfigurePage() {
252
252
  ${edit?.kind === 'cli-new' ? html`
253
253
  <${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
254
254
  onClose=${close} submitLabel="Create"
255
+ onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
255
256
  onSubmit=${async (v) => {
256
257
  try { await createCli(v); setToast(`created CLI · ${v.name}`); }
257
258
  catch (e) { setToast(e.message, 'error'); throw e; }
@@ -259,7 +260,7 @@ export function ConfigurePage() {
259
260
 
260
261
  ${edit?.kind === 'cli-edit' ? html`
261
262
  <${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
262
- readOnlyKeys=${edit.payload.builtin ? ['type', 'command'] : []}
263
+ readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
263
264
  initial=${{
264
265
  ...edit.payload,
265
266
  args: (edit.payload.args || []).join(' '),
@@ -267,6 +268,7 @@ export function ConfigurePage() {
267
268
  resumeIdArgs: (edit.payload.resumeIdArgs || []).join(' '),
268
269
  }}
269
270
  onClose=${close}
271
+ onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
270
272
  onSubmit=${async (v) => {
271
273
  try {
272
274
  const patch = {
@@ -275,8 +277,6 @@ export function ConfigurePage() {
275
277
  resumeArgs: typeof v.resumeArgs === 'string' ? v.resumeArgs.split(/\s+/).filter(Boolean) : v.resumeArgs,
276
278
  resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
277
279
  };
278
- // command is locked on builtins — drop any tampered value.
279
- if (edit.payload.builtin) delete patch.command;
280
280
  await updateCli(edit.payload.id, patch);
281
281
  setToast('saved');
282
282
  } catch (e) { setToast(e.message, 'error'); throw e; }
package/server.js CHANGED
@@ -393,7 +393,92 @@ app.put('/api/config', asyncH(async (req, res) => {
393
393
  res.json(decorateConfigWithProbes(cfg));
394
394
  }));
395
395
 
396
- // ---- CLIs ----
396
+ // ---- CLI probe / test ----
397
+ //
398
+ // Run the user's configured command with `--version` and report back
399
+ // stdout/stderr + whether the output looks like the claimed CLI type.
400
+ // Used by the Configure page "Test" button so the user can verify the
401
+ // command resolves + actually launches the right tool BEFORE saving.
402
+ // Body: { command, args?, shell?, type? }. args is ignored for the
403
+ // version probe — we always append `--version` directly so the user's
404
+ // runtime args (e.g. --dangerously-skip-permissions) don't perturb the
405
+ // quick probe.
406
+ app.post('/api/clis/test', asyncH(async (req, res) => {
407
+ const { spawn } = require('node:child_process');
408
+ const body = req.body || {};
409
+ const command = String(body.command || '').trim();
410
+ const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
411
+ const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
412
+ if (!command) return res.status(400).json({ error: 'command required' });
413
+
414
+ // Build the test exec. Same shell-wrapping rules as resolveCommand,
415
+ // but we force `--version` as the only arg and we DROP `-NoExit`
416
+ // from the pwsh wrapper so pwsh terminates after printing.
417
+ let exe, args;
418
+ const cmd = command.replace(/^\.[\\\/]/, '');
419
+ const versionArg = '--version';
420
+ if (shell === 'pwsh') {
421
+ const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
422
+ exe = 'pwsh.exe';
423
+ args = ['-NoLogo', '-Command', joined];
424
+ } else if (shell === 'cmd') {
425
+ exe = process.env.ComSpec || 'cmd.exe';
426
+ args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
427
+ } else if (path.isAbsolute(cmd)) {
428
+ const ext = path.extname(cmd).toLowerCase();
429
+ if (ext === '.cmd' || ext === '.bat') {
430
+ exe = process.env.ComSpec || 'cmd.exe';
431
+ args = ['/d', '/s', '/c', cmd, versionArg];
432
+ } else if (ext === '.ps1') {
433
+ exe = 'powershell.exe';
434
+ args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
435
+ } else {
436
+ exe = cmd;
437
+ args = [versionArg];
438
+ }
439
+ } else {
440
+ exe = process.env.ComSpec || 'cmd.exe';
441
+ args = ['/d', '/s', '/c', cmd, versionArg];
442
+ }
443
+
444
+ const t0 = Date.now();
445
+ let stdout = '';
446
+ let stderr = '';
447
+ let exitCode = null;
448
+ let timedOut = false;
449
+ let spawnError = null;
450
+ try {
451
+ const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
452
+ const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
453
+ child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
454
+ child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
455
+ exitCode = await new Promise((resolve, reject) => {
456
+ child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
457
+ child.on('error', (err) => { clearTimeout(killer); reject(err); });
458
+ });
459
+ } catch (e) {
460
+ spawnError = String(e && e.message || e);
461
+ }
462
+ const durationMs = Date.now() - t0;
463
+
464
+ const out = (stdout + '\n' + stderr).toLowerCase();
465
+ const PATTERNS = {
466
+ claude: /claude/,
467
+ codex: /codex|openai/,
468
+ copilot: /copilot/,
469
+ };
470
+ const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
471
+ const ok = !spawnError && !timedOut && exitCode === 0;
472
+ res.json({
473
+ ok, exitCode, durationMs, timedOut, spawnError,
474
+ stdout: stdout.trim(),
475
+ stderr: stderr.trim(),
476
+ matchedType,
477
+ expectedType: type,
478
+ spawned: { exe, args },
479
+ });
480
+ }));
481
+
397
482
  // ---- folders ----
398
483
 
399
484
  app.get('/api/folders', asyncH(async (_req, res) => {