@bakapiano/ccsm 0.15.2 → 0.15.4

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/lib/workspace.js CHANGED
@@ -95,8 +95,11 @@ function nextWorkspaceName(existing) {
95
95
  throw new Error('Could not allocate workspace name');
96
96
  }
97
97
 
98
- async function findOrCreateWorkspace({ workDir, repos, requireUnused = true }) {
99
- const all = await listWorkspaces({ workDir, repos });
98
+ async function findOrCreateWorkspace({ workDir, repos, busyPaths = [], requireUnused = true }) {
99
+ // Without busyPaths, every workspace looks free → find() always
100
+ // returns ws-1 → every new session piles into ws-1. Callers must
101
+ // pass the cwds of currently-running persisted sessions.
102
+ const all = await listWorkspaces({ workDir, repos, busyPaths });
100
103
  if (requireUnused) {
101
104
  const free = all.find((w) => !w.inUse);
102
105
  if (free) return { workspace: free, created: false };
@@ -104,7 +107,7 @@ async function findOrCreateWorkspace({ workDir, repos, requireUnused = true }) {
104
107
  const name = nextWorkspaceName(all);
105
108
  const wsPath = path.join(workDir, name);
106
109
  await ensureDir(wsPath);
107
- const ws = await describeWorkspace(wsPath, repos, []);
110
+ const ws = await describeWorkspace(wsPath, repos, busyPaths);
108
111
  return { workspace: ws, created: true };
109
112
  }
110
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.15.2",
3
+ "version": "0.15.4",
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",
package/server.js CHANGED
@@ -652,9 +652,17 @@ app.post('/api/sessions/new', async (req, res) => {
652
652
  workspace = all.find((w) => w.name === req.body.workspace);
653
653
  if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
654
654
  } else {
655
+ // Collect cwds of currently-running persisted sessions so
656
+ // findOrCreateWorkspace can flag those workspaces as in-use and
657
+ // skip past ws-1 when it's already occupied.
658
+ const running = await persistedSessions.loadAll();
659
+ const busyPaths = running
660
+ .filter((s) => s.status === 'running')
661
+ .map((s) => s.cwd);
655
662
  const r = await findOrCreateWorkspace({
656
663
  workDir: cfg.workDir,
657
664
  repos: cfg.repos,
665
+ busyPaths,
658
666
  requireUnused: true,
659
667
  });
660
668
  workspace = r.workspace;
@@ -1145,18 +1153,108 @@ function findAppModeBrowser() {
1145
1153
  return null;
1146
1154
  }
1147
1155
 
1148
- // Auto-open the frontend in a browser when ccsm boots. Strategy: try a
1149
- // chromeless app window first (Edge/Chrome --app=); if neither is
1150
- // installed, fall back to the OS default browser as a regular tab. On
1151
- // non-Windows we skip the bundled launcher isn't ported yet.
1156
+ // Look for a Chrome/Edge PWA that the user already installed locally
1157
+ // pointing at the ccsm frontend. When found, we launch it via
1158
+ // `chrome.exe --profile-directory=... --app-id=<id>` same as the
1159
+ // shortcut Start Menu creates at install time. That path opens the
1160
+ // PWA fully chromeless (respects manifest display:standalone + WCO).
1161
+ // Without this we'd fall back to `--app=<URL> --user-data-dir=<ours>`
1162
+ // which uses an isolated profile that doesn't see the install, so
1163
+ // Chrome shows a minimal-ui address bar.
1164
+ function findInstalledCcsmPwa() {
1165
+ if (process.platform !== 'win32') return null;
1166
+ const appData = process.env.APPDATA;
1167
+ if (!appData) return null;
1168
+ const fs = require('node:fs');
1169
+ const startMenu = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
1170
+ const dirs = [
1171
+ path.join(startMenu, 'Chrome Apps'),
1172
+ path.join(startMenu, 'Edge Apps'),
1173
+ ];
1174
+ const candidates = [];
1175
+ for (const dir of dirs) {
1176
+ let names;
1177
+ try { names = fs.readdirSync(dir); } catch { continue; }
1178
+ for (const name of names) {
1179
+ if (!name.toLowerCase().endsWith('.lnk')) continue;
1180
+ // Filter by filename — Chrome names PWA shortcuts after the
1181
+ // manifest's short_name/name. CCSM matches our manifest.
1182
+ if (!/ccsm/i.test(name)) continue;
1183
+ const full = path.join(dir, name);
1184
+ try {
1185
+ candidates.push({ name, path: full, mtime: fs.statSync(full).mtimeMs });
1186
+ } catch {}
1187
+ }
1188
+ }
1189
+ if (candidates.length === 0) return null;
1190
+ // Newest install wins (covers the case where the user re-installed
1191
+ // and accumulated CCSM, CCSM (1), etc.).
1192
+ candidates.sort((a, b) => b.mtime - a.mtime);
1193
+ // Resolve via WScript.Shell COM. Single PowerShell call enumerates
1194
+ // every candidate; we stop at the first one whose target looks like
1195
+ // a Chrome/Edge binary and whose args carry an --app-id.
1196
+ const { spawnSync } = require('node:child_process');
1197
+ const psPaths = candidates
1198
+ .map((c) => `'${c.path.replace(/'/g, "''")}'`).join(',');
1199
+ const script = `
1200
+ $ErrorActionPreference = 'SilentlyContinue'
1201
+ $wsh = New-Object -ComObject WScript.Shell
1202
+ foreach ($p in @(${psPaths})) {
1203
+ $sc = $wsh.CreateShortcut($p)
1204
+ Write-Output ($sc.TargetPath + '|' + $sc.Arguments)
1205
+ }`;
1206
+ const r = spawnSync('powershell.exe',
1207
+ ['-NoProfile', '-NonInteractive', '-Command', script],
1208
+ { encoding: 'utf8', windowsHide: true });
1209
+ if (r.status !== 0 || !r.stdout) return null;
1210
+ for (const line of r.stdout.split(/\r?\n/)) {
1211
+ if (!line.trim()) continue;
1212
+ const sep = line.indexOf('|');
1213
+ if (sep < 0) continue;
1214
+ const target = line.slice(0, sep).trim();
1215
+ const args = line.slice(sep + 1).trim();
1216
+ if (!/chrome(_proxy)?\.exe$|msedge(_proxy)?\.exe$/i.test(target)) continue;
1217
+ const appId = (args.match(/--app-id=(\S+)/) || [])[1];
1218
+ if (!appId) continue;
1219
+ const profile = (args.match(/--profile-directory=(\S+)/) || [])[1] || 'Default';
1220
+ return { browserPath: target, appId, profile };
1221
+ }
1222
+ return null;
1223
+ }
1224
+
1225
+ // Auto-open the frontend in a browser when ccsm boots. Strategy:
1226
+ // 1. If the user already installed the CCSM PWA, launch THAT (fully
1227
+ // chromeless via --app-id, uses user's main browser profile).
1228
+ // 2. Otherwise try a generic --app= window in an isolated profile —
1229
+ // this shows a thin minimal-ui address bar but at least it's
1230
+ // a dedicated window.
1231
+ // 3. Fall back to the OS default browser as a regular tab.
1232
+ // On non-Windows we skip — the bundled launcher isn't ported yet.
1152
1233
  function openInBrowser(url) {
1153
1234
  if (process.platform !== 'win32') return { kind: 'none', child: null };
1154
1235
  const { spawn } = require('node:child_process');
1155
1236
  const fs = require('node:fs');
1237
+
1238
+ const installed = findInstalledCcsmPwa();
1239
+ if (installed) {
1240
+ console.log(`[ccsm] launching installed PWA · app-id=${installed.appId} profile=${installed.profile}`);
1241
+ const child = spawn(
1242
+ installed.browserPath,
1243
+ [
1244
+ `--profile-directory=${installed.profile}`,
1245
+ `--app-id=${installed.appId}`,
1246
+ ],
1247
+ { detached: true, stdio: 'ignore' }
1248
+ );
1249
+ child.unref();
1250
+ return { kind: 'pwa', child };
1251
+ }
1252
+
1156
1253
  const exe = findAppModeBrowser();
1157
1254
  if (exe) {
1158
1255
  const profileDir = path.join(DATA_DIR, 'browser-profile');
1159
1256
  fs.mkdirSync(profileDir, { recursive: true });
1257
+ console.log(`[ccsm] no installed PWA found · falling back to --app= window`);
1160
1258
  const child = spawn(
1161
1259
  exe,
1162
1260
  [