@bakapiano/ccsm 0.13.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.13.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",
@@ -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) => {
@@ -852,6 +861,14 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
852
861
  // Already running and attached → no-op, just return its id.
853
862
  const live = webTerminal.get(record.id);
854
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
+ }
855
872
  return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
856
873
  }
857
874
  const cfg = await loadConfig();
@@ -1007,55 +1024,60 @@ app.post('/api/upgrade', asyncH(async (req, res) => {
1007
1024
  if (upgradeInFlight) {
1008
1025
  return res.status(409).json({ error: 'upgrade already in progress' });
1009
1026
  }
1010
- upgradeInFlight = true;
1011
- const target = String((req.body && req.body.target) || 'latest');
1027
+ const body = req.body || {};
1028
+ const target = String(body.target || 'latest');
1012
1029
  // Refuse anything that doesn't look like a semver dist-tag or version
1013
1030
  // — defends against `;` etc. winding up in the spawn argv even though
1014
1031
  // we don't shell out.
1015
1032
  if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
1016
- upgradeInFlight = false;
1017
1033
  return res.status(400).json({ error: `invalid target: ${target}` });
1018
1034
  }
1019
- console.log(`[upgrade] starting npm i -g @bakapiano/ccsm@${target}`);
1020
- 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)' : ''}`);
1045
+
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];
1021
1059
 
1022
- // Defer the actual spawn so the HTTP response flushes first.
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.
1023
1064
  setImmediate(() => {
1024
1065
  const { spawn } = require('node:child_process');
1025
- const npmExe = process.platform === 'win32' ? 'npm.cmd' : 'npm';
1026
- const args = ['i', '-g', `@bakapiano/ccsm@${target}`];
1027
- const child = spawn(npmExe, args, {
1028
- detached: true,
1029
- stdio: 'ignore',
1030
- windowsHide: true,
1031
- shell: false,
1032
- });
1033
- child.on('error', (e) => {
1034
- console.error('[upgrade] npm spawn failed:', e.message);
1035
- upgradeInFlight = false;
1036
- });
1037
- child.on('exit', (code) => {
1038
- 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);
1039
1077
  upgradeInFlight = false;
1040
- if (code !== 0) return;
1041
- // Install succeeded → spawn a fresh ccsm and shut down. The
1042
- // launcher already detaches on its own.
1043
- try {
1044
- const ccsmCmd = process.platform === 'win32' ? 'ccsm.cmd' : 'ccsm';
1045
- const respawn = spawn(ccsmCmd, [], {
1046
- detached: true,
1047
- stdio: 'ignore',
1048
- windowsHide: true,
1049
- shell: false,
1050
- env: { ...process.env, CCSM_NO_BROWSER: '1' },
1051
- });
1052
- respawn.unref();
1053
- } catch (e) {
1054
- console.error('[upgrade] respawn failed:', e.message);
1055
- }
1056
- setTimeout(() => gracefulShutdown('upgrade'), 1500);
1057
- });
1058
- child.unref();
1078
+ return;
1079
+ }
1080
+ setTimeout(() => gracefulShutdown('upgrade'), 500);
1059
1081
  });
1060
1082
  }));
1061
1083