@bakapiano/ccsm 0.12.0 → 0.14.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.
@@ -34,6 +34,19 @@ const readline = require('node:readline');
34
34
  const POLL_MS = 1_500;
35
35
  const WINDOW_MS = 5 * 60_000;
36
36
 
37
+ // Module-level set of upstream session UUIDs known to belong to some
38
+ // ccsm session — either persisted on disk (seeded at watcher start via
39
+ // the excludeIds arg), or captured by another in-flight watcher in
40
+ // THIS server's lifetime. Grows monotonically; we never unclaim because
41
+ // the file-level cliSessionId is sticky too.
42
+ //
43
+ // Why module-level rather than per-watcher: when two ccsm sessions
44
+ // spawn back-to-back in the same cwd, the FIRST watcher might capture
45
+ // its UUID AFTER the second watcher started. The second watcher needs
46
+ // to see "this UUID just got claimed" without us threading a notify
47
+ // callback. Sharing the set in module scope is the simplest fix.
48
+ const claimedIds = new Set();
49
+
37
50
  const profiles = {
38
51
  claude: {
39
52
  dirFor: (cwd) => path.join(os.homedir(), '.claude', 'projects', claudeSlug(cwd)),
@@ -76,12 +89,17 @@ const profiles = {
76
89
  },
77
90
  };
78
91
 
79
- function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDOW_MS }) {
92
+ function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDOW_MS, excludeIds = [] }) {
80
93
  const profile = profiles[cliType];
81
94
  if (!profile) return () => {};
82
95
  const dir = profile.dirFor(cwd);
83
96
  const spawnAt = Date.now();
84
- console.log(`[cliSessionWatcher] start ${cliType} dir=${dir} cwd=${cwd}`);
97
+ // Seed module-level claimedIds with the caller's view of "already
98
+ // assigned" UUIDs (typically persistedSessions cliSessionIds). The
99
+ // poll loop reads claimedIds fresh every tick so concurrent watchers
100
+ // see each other's captures.
101
+ for (const id of (excludeIds || [])) if (id) claimedIds.add(id);
102
+ console.log(`[cliSessionWatcher] start ${cliType} dir=${dir} cwd=${cwd}${claimedIds.size ? ` claimed=${claimedIds.size}` : ''}`);
85
103
 
86
104
  let stopped = false;
87
105
  let captured = false;
@@ -104,6 +122,10 @@ function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDO
104
122
  const finish = (sessionId) => {
105
123
  if (stopped) return;
106
124
  captured = true;
125
+ // Add to module-level claimedIds BEFORE firing onCapture so any
126
+ // concurrent watcher polling at the same instant won't also grab
127
+ // this UUID. Sticky for the lifetime of the server.
128
+ claimedIds.add(sessionId);
107
129
  cleanup();
108
130
  console.log(`[cliSessionWatcher] captured ${cliType} ${sessionId}`);
109
131
  try { onCapture?.(sessionId); } catch (e) { console.error('[cliSessionWatcher] onCapture:', e); }
@@ -128,6 +150,10 @@ function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDO
128
150
  if (profile.filePattern && !profile.filePattern.test(base)) continue;
129
151
  const id = profile.parseId(base);
130
152
  if (!id) continue;
153
+ // Already-claimed-by-another-ccsm-session UUIDs are never ours.
154
+ // claimedIds is module-level so concurrent watchers see each
155
+ // other's captures (not just the snapshot at our spawn time).
156
+ if (claimedIds.has(id)) { rejected.add(entryPath); continue; }
131
157
  let st;
132
158
  try { st = await fsp.stat(entryPath); } catch { continue; }
133
159
  // Mtime gate is re-evaluated every poll: don't memoise it. If the
@@ -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.14.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; }
@@ -29,17 +29,34 @@ if (process.platform !== 'win32') {
29
29
  // always means a first-time `npx @bakapiano/ccsm` gets the full "click
30
30
  // to wake" UX without needing a separate `npm i -g`.
31
31
 
32
+ // Returns { ccsmCmd, isSandbox } where isSandbox=true means this install
33
+ // went into a non-default prefix (e.g. `npm i -g --prefix=<tmp>` from
34
+ // the in-app upgrade's test mode). For sandboxed installs we DO NOT
35
+ // touch the global launcher.vbs / ccsm:// protocol registration —
36
+ // otherwise we'd repoint them at a directory that gets deleted later.
32
37
  function findCcsmCmd() {
33
- const prefix = process.env.npm_config_prefix
34
- || (() => {
35
- try {
36
- const r = spawnSync('npm', ['config', 'get', 'prefix'], { encoding: 'utf8', shell: true });
37
- return r.stdout?.trim() || null;
38
- } catch { return null; }
39
- })();
40
- if (!prefix) return null;
38
+ const givenPrefix = process.env.npm_config_prefix || null;
39
+ let defaultPrefix = null;
40
+ try {
41
+ // npm config get prefix when run WITHOUT --prefix returns the
42
+ // user's default global prefix; with --prefix it echoes the flag.
43
+ // We want the env-independent default so we can compare. INIT_CWD
44
+ // and a clean spawn give us the user-default value.
45
+ const r = spawnSync('npm', ['config', 'get', 'prefix'], {
46
+ encoding: 'utf8', shell: true,
47
+ env: { ...process.env, npm_config_prefix: '' },
48
+ });
49
+ defaultPrefix = r.stdout?.trim() || null;
50
+ } catch {}
51
+ const prefix = givenPrefix || defaultPrefix;
52
+ if (!prefix) return { ccsmCmd: null, isSandbox: false };
41
53
  const candidate = path.join(prefix, 'ccsm.cmd');
42
- return fs.existsSync(candidate) ? candidate : null;
54
+ const isSandbox = !!(givenPrefix && defaultPrefix
55
+ && path.resolve(givenPrefix).toLowerCase() !== path.resolve(defaultPrefix).toLowerCase());
56
+ return {
57
+ ccsmCmd: fs.existsSync(candidate) ? candidate : null,
58
+ isSandbox,
59
+ };
43
60
  }
44
61
 
45
62
  // Write a tiny VBScript wrapper that ccsm:// dispatches into. Why VBS:
@@ -91,13 +108,17 @@ function registerProtocol(vbsPath) {
91
108
  }
92
109
  }
93
110
 
94
- const ccsmCmd = (() => {
95
- try { return findCcsmCmd(); } catch { return null; }
111
+ const { ccsmCmd, isSandbox } = (() => {
112
+ try { return findCcsmCmd(); } catch { return { ccsmCmd: null, isSandbox: false }; }
96
113
  })();
97
114
  if (!ccsmCmd) {
98
115
  warn('could not locate ccsm.cmd · skipping protocol registration');
99
116
  process.exit(0);
100
117
  }
118
+ if (isSandbox) {
119
+ log(`sandbox install detected (prefix=${process.env.npm_config_prefix}) · skipping global launcher.vbs + protocol registration + auto-launch`);
120
+ process.exit(0);
121
+ }
101
122
 
102
123
  try {
103
124
  const vbsPath = writeLauncherVbs(ccsmCmd);
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // In-app upgrade helper · spawned detached by /api/upgrade.
5
+ //
6
+ // The previous implementation kicked off `npm i -g` directly from the
7
+ // running server. On Windows that fails with EBUSY: npm tries to rename
8
+ // the package directory but the server has files open inside it.
9
+ //
10
+ // This script breaks the cycle:
11
+ //
12
+ // 1. Server validates the upgrade request, spawns this helper detached
13
+ // with [target, port, pid] argv, sends 200 OK, then gracefulShutdowns.
14
+ // 2. Helper waits for the old port to free up + the old pid to die.
15
+ // 3. Helper runs `npm i -g @bakapiano/ccsm@<target>` synchronously.
16
+ // 4. On success it spawns `ccsm` detached (which spins up the new
17
+ // backend on the same port) and exits.
18
+ //
19
+ // Logs everything to ~/.ccsm/upgrade.log so a failed upgrade is
20
+ // debuggable without the user needing to re-run the command manually.
21
+ //
22
+ // Argv: node upgrade-helper.js <target> <port> <pid> [installPrefix] [respawn=1|0]
23
+ // - installPrefix: when set, runs `npm i -g --prefix=<this>` so the
24
+ // global install can be redirected to a sandbox dir for testing
25
+ // against a live prod install without disturbing it. Respawn then
26
+ // uses <prefix>/ccsm.cmd (Windows) or <prefix>/bin/ccsm (posix).
27
+ // - respawn: '0' skips the final ccsm respawn (also useful for tests).
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const os = require('node:os');
32
+ const net = require('node:net');
33
+ const { spawn, spawnSync } = require('node:child_process');
34
+
35
+ const target = process.argv[2] || 'latest';
36
+ const oldPort = Number(process.argv[3] || 7777);
37
+ const oldPid = Number(process.argv[4] || 0);
38
+ const installPrefix = process.argv[5] || '';
39
+ const doRespawn = process.argv[6] !== '0';
40
+
41
+ const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
42
+ const LOG = path.join(HOME, 'upgrade.log');
43
+ try { fs.mkdirSync(HOME, { recursive: true }); } catch {}
44
+
45
+ function log(msg) {
46
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
47
+ try { fs.appendFileSync(LOG, line); } catch {}
48
+ }
49
+
50
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
51
+
52
+ // Returns true once nothing answers on host:port within timeoutMs.
53
+ function portFree(port, timeoutMs = 800) {
54
+ return new Promise((resolve) => {
55
+ const s = new net.Socket();
56
+ let settled = false;
57
+ const finish = (free) => { if (settled) return; settled = true; try { s.destroy(); } catch {} resolve(free); };
58
+ s.setTimeout(timeoutMs);
59
+ s.once('connect', () => finish(false));
60
+ s.once('timeout', () => finish(true));
61
+ s.once('error', () => finish(true));
62
+ s.connect(port, '127.0.0.1');
63
+ });
64
+ }
65
+
66
+ function pidAlive(pid) {
67
+ if (!pid) return false;
68
+ try { process.kill(pid, 0); return true; }
69
+ catch (e) { return e.code === 'EPERM'; }
70
+ }
71
+
72
+ (async () => {
73
+ log(`start · target=${target} oldPort=${oldPort} oldPid=${oldPid}${installPrefix ? ` prefix=${installPrefix}` : ''}${!doRespawn ? ' (no respawn)' : ''}`);
74
+
75
+ // Wait up to 30s for the old server to be gone. Both port-free AND
76
+ // pid-dead so we don't fight npm's rename for a stale file handle.
77
+ const deadline = Date.now() + 30_000;
78
+ while (Date.now() < deadline) {
79
+ const free = await portFree(oldPort);
80
+ const dead = !pidAlive(oldPid);
81
+ if (free && dead) break;
82
+ await sleep(250);
83
+ }
84
+ log(`old server gone (or 30s elapsed) · running npm install`);
85
+
86
+ // npm.cmd is a batch wrapper on Windows; spawn it via cmd.exe /c so
87
+ // we don't need shell:true (which would mean argv quoting). target
88
+ // has already been regex-validated server-side so this is safe.
89
+ const isWin = process.platform === 'win32';
90
+ const arg = `@bakapiano/ccsm@${target}`;
91
+ const npmArgs = ['i', '-g'];
92
+ if (installPrefix) {
93
+ try { fs.mkdirSync(installPrefix, { recursive: true }); } catch {}
94
+ npmArgs.push(`--prefix=${installPrefix}`);
95
+ }
96
+ npmArgs.push(arg);
97
+ let r;
98
+ if (isWin) {
99
+ r = spawnSync(process.env.ComSpec || 'cmd.exe',
100
+ ['/d', '/s', '/c', 'npm', ...npmArgs],
101
+ { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
102
+ } else {
103
+ r = spawnSync('npm', npmArgs,
104
+ { stdio: ['ignore', 'pipe', 'pipe'] });
105
+ }
106
+ const stdout = r.stdout?.toString().trim();
107
+ const stderr = r.stderr?.toString().trim();
108
+ log(`npm exit=${r.status}${stdout ? `\nSTDOUT:\n${stdout}` : ''}${stderr ? `\nSTDERR:\n${stderr}` : ''}`);
109
+ if (r.status !== 0) {
110
+ log(`upgrade failed · not respawning`);
111
+ process.exit(1);
112
+ }
113
+
114
+ if (!doRespawn) {
115
+ log(`respawn skipped (respawn=0)`);
116
+ return;
117
+ }
118
+
119
+ // Respawn ccsm. With installPrefix the binary lives there; otherwise
120
+ // it's on PATH from the global npm install. The launcher handles
121
+ // detect-or-spawn-server and detaches.
122
+ //
123
+ // On Windows, CreateProcess refuses to spawn .cmd / .bat directly —
124
+ // they're cmd.exe scripts, not native exes. Route through cmd.exe /c
125
+ // so it loads the wrapper.
126
+ const ccsmCmd = installPrefix
127
+ ? (isWin ? path.join(installPrefix, 'ccsm.cmd') : path.join(installPrefix, 'bin', 'ccsm'))
128
+ : (isWin ? 'ccsm.cmd' : 'ccsm');
129
+ const childEnv = { ...process.env, CCSM_NO_BROWSER: '1' };
130
+ let exe, exeArgs;
131
+ if (isWin) {
132
+ exe = process.env.ComSpec || 'cmd.exe';
133
+ exeArgs = ['/d', '/s', '/c', ccsmCmd];
134
+ } else {
135
+ exe = ccsmCmd;
136
+ exeArgs = [];
137
+ }
138
+ try {
139
+ const child = spawn(exe, exeArgs, {
140
+ detached: true,
141
+ stdio: 'ignore',
142
+ windowsHide: true,
143
+ shell: false,
144
+ env: childEnv,
145
+ });
146
+ child.unref();
147
+ log(`respawned ${ccsmCmd} (via ${path.basename(exe)})`);
148
+ } catch (e) {
149
+ log(`respawn failed: ${e.message}`);
150
+ process.exit(1);
151
+ }
152
+ })().catch((e) => {
153
+ log(`fatal: ${e.message}`);
154
+ process.exit(1);
155
+ });
package/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const path = require('node:path');
5
+ const os = require('node:os');
5
6
  const express = require('express');
6
7
 
7
8
  const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
@@ -267,16 +268,24 @@ async function maybeWatchCliSessionId({ cli, cwd, ccsmSessionId }) {
267
268
  // If a previous watcher was still alive on this id (e.g. fast restart),
268
269
  // tear it down first.
269
270
  stopWatcher(ccsmSessionId);
271
+ let excludeIds = [];
270
272
  try {
271
- const existing = await persistedSessions.get(ccsmSessionId);
273
+ const all = await persistedSessions.loadAll();
274
+ const existing = all.find((s) => s.id === ccsmSessionId);
272
275
  if (existing?.cliSessionId) {
273
276
  console.log(`[cliSessionId] skip watcher · ${cli.type} session already known (${existing.cliSessionId})`);
274
277
  return;
275
278
  }
279
+ // Other ccsm sessions' upstream UUIDs — exclude so the watcher
280
+ // doesn't misclaim a sibling's actively-touched transcript.
281
+ excludeIds = all
282
+ .filter((s) => s.id !== ccsmSessionId && s.cliSessionId)
283
+ .map((s) => s.cliSessionId);
276
284
  } catch {}
277
285
  const cleanup = cliSessionWatcher.captureSessionId({
278
286
  cliType: cli.type,
279
287
  cwd,
288
+ excludeIds,
280
289
  onCapture: (cliSessionId) => {
281
290
  activeWatchers.delete(ccsmSessionId);
282
291
  persistedSessions.update(ccsmSessionId, { cliSessionId }).catch((e) => {
@@ -393,7 +402,92 @@ app.put('/api/config', asyncH(async (req, res) => {
393
402
  res.json(decorateConfigWithProbes(cfg));
394
403
  }));
395
404
 
396
- // ---- CLIs ----
405
+ // ---- CLI probe / test ----
406
+ //
407
+ // Run the user's configured command with `--version` and report back
408
+ // stdout/stderr + whether the output looks like the claimed CLI type.
409
+ // Used by the Configure page "Test" button so the user can verify the
410
+ // command resolves + actually launches the right tool BEFORE saving.
411
+ // Body: { command, args?, shell?, type? }. args is ignored for the
412
+ // version probe — we always append `--version` directly so the user's
413
+ // runtime args (e.g. --dangerously-skip-permissions) don't perturb the
414
+ // quick probe.
415
+ app.post('/api/clis/test', asyncH(async (req, res) => {
416
+ const { spawn } = require('node:child_process');
417
+ const body = req.body || {};
418
+ const command = String(body.command || '').trim();
419
+ const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
420
+ const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
421
+ if (!command) return res.status(400).json({ error: 'command required' });
422
+
423
+ // Build the test exec. Same shell-wrapping rules as resolveCommand,
424
+ // but we force `--version` as the only arg and we DROP `-NoExit`
425
+ // from the pwsh wrapper so pwsh terminates after printing.
426
+ let exe, args;
427
+ const cmd = command.replace(/^\.[\\\/]/, '');
428
+ const versionArg = '--version';
429
+ if (shell === 'pwsh') {
430
+ const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
431
+ exe = 'pwsh.exe';
432
+ args = ['-NoLogo', '-Command', joined];
433
+ } else if (shell === 'cmd') {
434
+ exe = process.env.ComSpec || 'cmd.exe';
435
+ args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
436
+ } else if (path.isAbsolute(cmd)) {
437
+ const ext = path.extname(cmd).toLowerCase();
438
+ if (ext === '.cmd' || ext === '.bat') {
439
+ exe = process.env.ComSpec || 'cmd.exe';
440
+ args = ['/d', '/s', '/c', cmd, versionArg];
441
+ } else if (ext === '.ps1') {
442
+ exe = 'powershell.exe';
443
+ args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
444
+ } else {
445
+ exe = cmd;
446
+ args = [versionArg];
447
+ }
448
+ } else {
449
+ exe = process.env.ComSpec || 'cmd.exe';
450
+ args = ['/d', '/s', '/c', cmd, versionArg];
451
+ }
452
+
453
+ const t0 = Date.now();
454
+ let stdout = '';
455
+ let stderr = '';
456
+ let exitCode = null;
457
+ let timedOut = false;
458
+ let spawnError = null;
459
+ try {
460
+ const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
461
+ const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
462
+ child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
463
+ child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
464
+ exitCode = await new Promise((resolve, reject) => {
465
+ child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
466
+ child.on('error', (err) => { clearTimeout(killer); reject(err); });
467
+ });
468
+ } catch (e) {
469
+ spawnError = String(e && e.message || e);
470
+ }
471
+ const durationMs = Date.now() - t0;
472
+
473
+ const out = (stdout + '\n' + stderr).toLowerCase();
474
+ const PATTERNS = {
475
+ claude: /claude/,
476
+ codex: /codex|openai/,
477
+ copilot: /copilot/,
478
+ };
479
+ const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
480
+ const ok = !spawnError && !timedOut && exitCode === 0;
481
+ res.json({
482
+ ok, exitCode, durationMs, timedOut, spawnError,
483
+ stdout: stdout.trim(),
484
+ stderr: stderr.trim(),
485
+ matchedType,
486
+ expectedType: type,
487
+ spawned: { exe, args },
488
+ });
489
+ }));
490
+
397
491
  // ---- folders ----
398
492
 
399
493
  app.get('/api/folders', asyncH(async (_req, res) => {
@@ -767,6 +861,14 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
767
861
  // Already running and attached → no-op, just return its id.
768
862
  const live = webTerminal.get(record.id);
769
863
  if (live && !live.exitedAt) {
864
+ // Pool says we're alive but the record may be stale (e.g. a prior
865
+ // markRunning got clobbered by an OLD entry's onExit before the
866
+ // respawn-guard landed, or boot mark-exited ran after a pool entry
867
+ // was already wired). Reconcile the file to match the pool so the
868
+ // frontend doesn't get stuck on "Resuming session…" forever.
869
+ if (record.status !== 'running' || record.pid !== live.meta.pid) {
870
+ try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
871
+ }
770
872
  return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
771
873
  }
772
874
  const cfg = await loadConfig();
@@ -922,55 +1024,60 @@ app.post('/api/upgrade', asyncH(async (req, res) => {
922
1024
  if (upgradeInFlight) {
923
1025
  return res.status(409).json({ error: 'upgrade already in progress' });
924
1026
  }
925
- upgradeInFlight = true;
926
- const target = String((req.body && req.body.target) || 'latest');
1027
+ const body = req.body || {};
1028
+ const target = String(body.target || 'latest');
927
1029
  // Refuse anything that doesn't look like a semver dist-tag or version
928
1030
  // — defends against `;` etc. winding up in the spawn argv even though
929
1031
  // we don't shell out.
930
1032
  if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
931
- upgradeInFlight = false;
932
1033
  return res.status(400).json({ error: `invalid target: ${target}` });
933
1034
  }
934
- console.log(`[upgrade] starting npm i -g @bakapiano/ccsm@${target}`);
935
- res.json({ ok: true, started: true, target });
1035
+ // Optional sandbox install prefix (for testing without disturbing the
1036
+ // user's real global ccsm). Validated as a plain absolute path so it
1037
+ // can't be a flag injection.
1038
+ const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
1039
+ if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
1040
+ return res.status(400).json({ error: 'installPrefix must be an absolute path' });
1041
+ }
1042
+ const respawn = body.respawn === false ? '0' : '1';
1043
+ upgradeInFlight = true;
1044
+ console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
936
1045
 
937
- // Defer the actual spawn so the HTTP response flushes first.
1046
+ // The helper runs OUTSIDE the package dir so npm can rename it
1047
+ // without fighting open file handles. Copy the script to os.tmpdir()
1048
+ // and spawn from there.
1049
+ const fsp = require('node:fs/promises');
1050
+ const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
1051
+ const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
1052
+ try {
1053
+ await fsp.copyFile(helperSrc, helperTmp);
1054
+ } catch (e) {
1055
+ upgradeInFlight = false;
1056
+ return res.status(500).json({ error: `helper copy failed: ${e.message}` });
1057
+ }
1058
+ const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn];
1059
+
1060
+ res.json({ ok: true, started: true, target, helper: helperTmp });
1061
+
1062
+ // Flush response, then spawn helper detached and gracefulShutdown so
1063
+ // the helper's npm install isn't fighting our open file handles.
938
1064
  setImmediate(() => {
939
1065
  const { spawn } = require('node:child_process');
940
- const npmExe = process.platform === 'win32' ? 'npm.cmd' : 'npm';
941
- const args = ['i', '-g', `@bakapiano/ccsm@${target}`];
942
- const child = spawn(npmExe, args, {
943
- detached: true,
944
- stdio: 'ignore',
945
- windowsHide: true,
946
- shell: false,
947
- });
948
- child.on('error', (e) => {
949
- console.error('[upgrade] npm spawn failed:', e.message);
950
- upgradeInFlight = false;
951
- });
952
- child.on('exit', (code) => {
953
- console.log(`[upgrade] npm exit ${code}`);
1066
+ try {
1067
+ const child = spawn(process.execPath, args, {
1068
+ detached: true,
1069
+ stdio: 'ignore',
1070
+ windowsHide: true,
1071
+ shell: false,
1072
+ });
1073
+ child.unref();
1074
+ console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
1075
+ } catch (e) {
1076
+ console.error('[upgrade] helper spawn failed:', e.message);
954
1077
  upgradeInFlight = false;
955
- if (code !== 0) return;
956
- // Install succeeded → spawn a fresh ccsm and shut down. The
957
- // launcher already detaches on its own.
958
- try {
959
- const ccsmCmd = process.platform === 'win32' ? 'ccsm.cmd' : 'ccsm';
960
- const respawn = spawn(ccsmCmd, [], {
961
- detached: true,
962
- stdio: 'ignore',
963
- windowsHide: true,
964
- shell: false,
965
- env: { ...process.env, CCSM_NO_BROWSER: '1' },
966
- });
967
- respawn.unref();
968
- } catch (e) {
969
- console.error('[upgrade] respawn failed:', e.message);
970
- }
971
- setTimeout(() => gracefulShutdown('upgrade'), 1500);
972
- });
973
- child.unref();
1078
+ return;
1079
+ }
1080
+ setTimeout(() => gracefulShutdown('upgrade'), 500);
974
1081
  });
975
1082
  }));
976
1083