@bakapiano/ccsm 0.2.0 → 0.4.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.
package/CLAUDE.md CHANGED
@@ -20,7 +20,7 @@ Then open http://localhost:7777.
20
20
  Default port `7777`, default workDir `~/ccsm-workspaces`. Config + snapshots live at `~/.ccsm/` (override with `CCSM_HOME=<path>`). All settings editable through the Config panel (`~/.ccsm/config.json` on disk). Notable knobs:
21
21
 
22
22
  - `port` (default `7777`) — preferred listen port. If taken, ccsm tries `+1..+9` then asks the OS for any free port. The startup log prints the actual URL so you always see where it ended up.
23
- - `autoOpenBrowser` (default true) — after `listen()` succeeds, ccsm spawns `cmd /c start "" <url>` to open the UI in the default browser. Disable for headless / nohup setups.
23
+ - `browserMode` (default `app`) — how to open the UI on server start. `app` finds Edge or Chrome and spawns it with `--app=<url> --user-data-dir=<DATA_DIR>/browser-profile` for a chromeless webview-style window (no tabs, no address bar). `tab` opens the default browser as a regular tab. `none` skips opening. Legacy `autoOpenBrowser: false` still maps to `none` for back-compat.
24
24
  - `claudeCommand` (default `"claude"`) — what gets `--resume`'d or freshly invoked inside the new terminal. Can be an exe (`claude`, `claude.exe`), a PowerShell alias or function (`ccp`), or any wrapper script — see `commandShell` below.
25
25
  - `terminal` — `wt` | `powershell` | `pwsh` | `cmd`. wt opens a fresh window per launch (`wt -w new` is set to defeat the "fold into existing window" setting some users have). The other three each spawn via `cmd /c start ... <shell>`.
26
26
  - `commandShell` (default `pwsh`) — only consulted when `terminal=wt`. Values `pwsh` / `powershell` wrap `claudeCommand` inside `<shell> -NoExit -NoLogo -Command "Set-Location ...; & '<cmd>' '<args>'..."` so PowerShell aliases / functions / profile-defined names (like `ccp` from `$PROFILE`) resolve. `none` runs the command directly via wt (raw `CreateProcess`) — fine if `claudeCommand` is an actual exe on PATH, broken for aliases. `pwsh` / `powershell` kinds already wrap natively so this knob doesn't affect them; `cmd` kind has no shell concept for aliases.
@@ -88,7 +88,8 @@ The alternative ("one repo per workspace") was explicitly rejected — don't ref
88
88
  | POST | `/api/sessions/new` | body `{repos, workspace?, launch?}` — picks/creates ws, clones missing repos, launches wt |
89
89
  | POST | `/api/sessions/finder` | opens a wt with `claude` in `D:\ccsm` and the `finderPrompt` as opening message |
90
90
  | POST | `/api/sessions/:id/resume` | body `{cwd}` — launches `wt -d <cwd> claude --resume <id>` |
91
- | POST | `/api/sessions/:id/focus` | walks up the claude PID parent chain to find the wt window, raises it via SetForegroundWindow |
91
+ | GET | `/api/sessions/recent` | recently-used sessions discovered from `~/.claude/projects/*/*.jsonl` mtimes, excluding currently-live ids |
92
+ | POST | `/api/sessions/:id/focus` | matches a wt window by title (cleaned of leading status glyphs) against the session's ai-title; falls back to PID-parent walk if no unique match |
92
93
  | GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
93
94
  | GET | `/api/health` | sanity ping |
94
95
 
@@ -112,6 +113,8 @@ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends again
112
113
 
113
114
  **Don't test wt launching via `node -e "..."` inside `bash -c`.** Backslashes get eaten by shell quoting and the JS string ends up malformed. Write a `.js` file and `node file.js` instead.
114
115
 
116
+ **focusBySession — title-based wt window matching.** Walking up the claude.exe PID parent chain and taking `MainWindowHandle` of the wt process *always* returns the same canonical window in modern multi-window single-process wt — clicking different sessions all focused the same window. `focusBySession` instead lists all visible wt windows (`EnumWindows` filtered by process name), strips the leading wt status glyph (`✳ `, `⠐ `, `⠠ ` …) and compares to the session's ai-title. Falls back to title-substring, then cwd-basename, then the old PID-parent walk when no unique match — at least the user sees *some* wt window even if it's the wrong tab. Caveat: for sessions sitting in an inactive tab of a multi-tab wt window, the window title shows the *active* tab's title — so we can't find them by title alone and the fallback is wrong-tab.
117
+
115
118
  **Focus helper (`lib/focus.js`).** One `powershell.exe -EncodedCommand <base64>` invocation per call, dispatched by `CCSM_FOCUS_MODE` env var into modes `list` (enumerate visible top-level windows owned by a process name), `focus-hwnd` (activate a specific HWND), `focus-pid` (walk parent chain to MainWindowHandle then activate). Encoded as UTF-16-LE-base64 — passing the C# block via stdin to `-Command -` silently produces no output, so we don't. Three gotchas baked in:
116
119
  1. C# `out _` discard breaks PowerShell 5.1's bundled C# compiler — use a named `uint dummy`.
117
120
  2. Windows blocks background processes from `SetForegroundWindow` — synthesize an Alt-key down/up via `keybd_event 0x12` first ("Alt-key trick") to qualify our process. Without it, `activated` returns false even though the window is found.
package/lib/config.js CHANGED
@@ -22,7 +22,10 @@ const DEFAULTS = {
22
22
  terminal: 'wt',
23
23
  commandShell: 'pwsh',
24
24
  autoFocusOnLaunch: true,
25
- autoOpenBrowser: true,
25
+ // 'app' — Edge/Chrome --app=<url> chromeless window (looks like a desktop app)
26
+ // 'tab' — open in default browser as a normal tab
27
+ // 'none' — don't open anything
28
+ browserMode: 'app',
26
29
  // Add the repos you most often need on hand. The "new session" button
27
30
  // clones any selected entries into the workspace before launching claude.
28
31
  // Example shape:
package/lib/focus.js CHANGED
@@ -192,6 +192,63 @@ async function focusByPid(pid) {
192
192
  return await runPsHelper('focus-pid', n);
193
193
  }
194
194
 
195
+ // Strip the leading wt status glyph + whitespace ("✳ ", "⠐ ", "⠠ " etc) so
196
+ // the title compares cleanly. wt prefixes the tab title with a Braille-style
197
+ // progress glyph or a sparkle/asterisk depending on activity.
198
+ function cleanWtTitle(t) {
199
+ return String(t || '').replace(/^[^\w一-鿿]+/, '').trim();
200
+ }
201
+
202
+ // Best-effort: focus the wt window that belongs to a specific session.
203
+ // Modern wt is single-process multi-window, so walking from claude.exe PID
204
+ // up to wt.exe + taking MainWindowHandle always returns the same canonical
205
+ // window regardless of which tab the session is actually in. Instead we
206
+ // list all wt windows and match by tab title against the session's AI
207
+ // title / cwd basename.
208
+ async function focusBySession({ pid, sessionId, title, cwd }) {
209
+ const procName = 'WindowsTerminal.exe';
210
+ const cands = await listWindowsOf(procName);
211
+
212
+ const cleanedTitle = title ? cleanWtTitle(title) : '';
213
+ const cwdBase = cwd ? require('node:path').basename(cwd) : '';
214
+
215
+ // 1. Exact match on cleaned ai-title
216
+ if (cleanedTitle) {
217
+ const exact = cands.filter((w) => cleanWtTitle(w.title) === cleanedTitle);
218
+ if (exact.length === 1) {
219
+ const r = await focusByHwnd(exact[0].hwnd);
220
+ return { ...r, matchedBy: 'title-exact', hwnd: exact[0].hwnd, windowTitle: exact[0].title };
221
+ }
222
+ }
223
+
224
+ // 2. Title substring match (some shells append " - <cwd>" etc)
225
+ if (cleanedTitle) {
226
+ const subs = cands.filter((w) => w.title.includes(cleanedTitle));
227
+ if (subs.length === 1) {
228
+ const r = await focusByHwnd(subs[0].hwnd);
229
+ return { ...r, matchedBy: 'title-substring', hwnd: subs[0].hwnd, windowTitle: subs[0].title };
230
+ }
231
+ }
232
+
233
+ // 3. Match by cwd basename (workspace name shows up in title for fresh launches)
234
+ if (cwdBase) {
235
+ const byCwd = cands.filter((w) => w.title.includes(cwdBase));
236
+ if (byCwd.length === 1) {
237
+ const r = await focusByHwnd(byCwd[0].hwnd);
238
+ return { ...r, matchedBy: 'cwd-basename', hwnd: byCwd[0].hwnd, windowTitle: byCwd[0].title };
239
+ }
240
+ }
241
+
242
+ // 4. Fall back to PID parent-chain walk (returns the wt process's
243
+ // canonical MainWindowHandle — may be the wrong window when wt is
244
+ // multi-window single-process, but better than nothing).
245
+ if (pid) {
246
+ const r = await focusByPid(pid);
247
+ return { ...r, matchedBy: 'pid-fallback', ambiguous: true };
248
+ }
249
+ return { ok: false, error: 'no match by title/cwd and no pid given', matchedBy: 'none' };
250
+ }
251
+
195
252
  async function focusByHwnd(hwnd) {
196
253
  return await runPsHelper('focus-hwnd', hwnd);
197
254
  }
@@ -228,6 +285,7 @@ async function focusNewlyOpenedHwnd(beforeHwnds, processName, opts = {}) {
228
285
 
229
286
  module.exports = {
230
287
  focusByPid,
288
+ focusBySession,
231
289
  focusByHwnd,
232
290
  listWindowsOf,
233
291
  snapshotWindowsOf,
package/lib/sessions.js CHANGED
@@ -110,8 +110,121 @@ async function listSessions() {
110
110
  .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
111
111
  }
112
112
 
113
+ // Pull cwd/title/firstTimestamp from a jsonl by reading the head (cwd lives in
114
+ // any user/assistant/attachment line) and tailing for the last ai-title.
115
+ async function readJsonlMetadata(jsonlPath) {
116
+ let fd;
117
+ try {
118
+ fd = await fs.open(jsonlPath, 'r');
119
+ const stat = await fd.stat();
120
+ if (stat.size === 0) return { size: 0 };
121
+
122
+ const HEAD = Math.min(stat.size, 128 * 1024);
123
+ const headBuf = Buffer.alloc(HEAD);
124
+ await fd.read(headBuf, 0, HEAD, 0);
125
+ const headText = headBuf.toString('utf8');
126
+
127
+ let cwd = null;
128
+ let gitBranch = null;
129
+ let firstTimestamp = null;
130
+ for (const line of headText.split('\n')) {
131
+ if (!line) continue;
132
+ if (cwd && firstTimestamp) break;
133
+ if (!line.includes('"cwd"') && !line.includes('"timestamp"')) continue;
134
+ try {
135
+ const obj = JSON.parse(line);
136
+ if (obj.cwd && !cwd) cwd = obj.cwd;
137
+ if (obj.gitBranch && !gitBranch) gitBranch = obj.gitBranch;
138
+ if (obj.timestamp && !firstTimestamp) firstTimestamp = obj.timestamp;
139
+ } catch {}
140
+ }
141
+
142
+ const TAIL = Math.min(stat.size, 512 * 1024);
143
+ const tailBuf = Buffer.alloc(TAIL);
144
+ await fd.read(tailBuf, 0, TAIL, Math.max(0, stat.size - TAIL));
145
+ const tailText = tailBuf.toString('utf8');
146
+ let title = null;
147
+ const tailLines = tailText.split('\n');
148
+ for (let i = tailLines.length - 1; i >= 0; i--) {
149
+ if (!tailLines[i].includes('"type":"ai-title"')) continue;
150
+ try {
151
+ const obj = JSON.parse(tailLines[i]);
152
+ if (obj.aiTitle) { title = obj.aiTitle; break; }
153
+ } catch {}
154
+ }
155
+
156
+ return { cwd, gitBranch, firstTimestamp, title, size: stat.size };
157
+ } catch {
158
+ return {};
159
+ } finally {
160
+ if (fd) await fd.close().catch(() => {});
161
+ }
162
+ }
163
+
164
+ // List every recently-used Claude session by enumerating ~/.claude/projects/
165
+ // *.jsonl files. Excludes session ids that are currently live (caller passes
166
+ // `excludeIds` from listSessions()). Sorted by file mtime desc.
167
+ async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
168
+ let projectDirs;
169
+ try {
170
+ projectDirs = await fs.readdir(PROJECTS_DIR);
171
+ } catch {
172
+ return [];
173
+ }
174
+
175
+ const candidates = [];
176
+ for (const slugDir of projectDirs) {
177
+ const dirPath = path.join(PROJECTS_DIR, slugDir);
178
+ let entries;
179
+ try {
180
+ entries = await fs.readdir(dirPath);
181
+ } catch {
182
+ continue;
183
+ }
184
+ for (const file of entries) {
185
+ if (!file.endsWith('.jsonl')) continue;
186
+ const sessionId = file.slice(0, -'.jsonl'.length);
187
+ if (excludeIds && excludeIds.has(sessionId)) continue;
188
+ const fullPath = path.join(dirPath, file);
189
+ try {
190
+ const stat = await fs.stat(fullPath);
191
+ if (stat.size === 0) continue;
192
+ candidates.push({
193
+ sessionId,
194
+ slug: slugDir,
195
+ jsonlPath: fullPath,
196
+ mtime: stat.mtimeMs,
197
+ });
198
+ } catch {}
199
+ }
200
+ }
201
+
202
+ candidates.sort((a, b) => b.mtime - a.mtime);
203
+ const top = candidates.slice(0, limit);
204
+
205
+ const results = await Promise.all(
206
+ top.map(async (c) => {
207
+ const meta = await readJsonlMetadata(c.jsonlPath);
208
+ const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
209
+ return {
210
+ sessionId: c.sessionId,
211
+ cwd: meta.cwd || null,
212
+ title: meta.title || null,
213
+ gitBranch: meta.gitBranch || null,
214
+ updatedAt: c.mtime,
215
+ startedAt: Number.isFinite(firstTs) ? firstTs : null,
216
+ jsonlPath: c.jsonlPath,
217
+ };
218
+ })
219
+ );
220
+
221
+ // Drop entries with no cwd — can't resume without one
222
+ return results.filter((r) => r.cwd);
223
+ }
224
+
113
225
  module.exports = {
114
226
  listSessions,
227
+ listRecentSessions,
115
228
  projectSlugForCwd,
116
229
  getLiveClaudePids,
117
230
  SESSIONS_DIR,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
package/public/app.js CHANGED
@@ -83,6 +83,29 @@ function renderSessions() {
83
83
  state.sessions.length ? `${state.sessions.length} live · last refresh ${new Date().toLocaleTimeString()}` : 'no live sessions';
84
84
  }
85
85
 
86
+ function renderRecent() {
87
+ const tb = $('#recentTable tbody');
88
+ tb.innerHTML = '';
89
+ const recent = state.recent || [];
90
+ for (const s of recent) {
91
+ const tr = document.createElement('tr');
92
+ tr.innerHTML = `
93
+ <td><div class="ellipsis" title="${escapeHtml(s.title || '')}">${escapeHtml(s.title || '(no title)')}</div>
94
+ <div class="mono small" title="${escapeHtml(s.sessionId)}">${escapeHtml(s.sessionId.slice(0,8))}…</div></td>
95
+ <td><div class="ellipsis mono" title="${escapeHtml(s.cwd || '')}">${escapeHtml(s.cwd || '')}</div></td>
96
+ <td class="mono small">${escapeHtml(s.gitBranch || '')}</td>
97
+ <td title="${escapeHtml(fmtTime(s.updatedAt))}">${escapeHtml(fmtAgo(s.updatedAt))}</td>
98
+ <td title="${escapeHtml(fmtTime(s.startedAt))}">${escapeHtml(fmtAgo(s.startedAt))}</td>
99
+ <td style="text-align:right;">
100
+ <button class="btn small btn-primary" data-continue="${escapeHtml(s.sessionId)}" data-cwd="${escapeHtml(s.cwd)}" title="open a new wt window with claude --resume">continue</button>
101
+ </td>
102
+ `;
103
+ tb.appendChild(tr);
104
+ }
105
+ $('#recentMeta').textContent =
106
+ recent.length ? `${recent.length} recent · last refresh ${new Date().toLocaleTimeString()}` : 'no recent sessions';
107
+ }
108
+
86
109
  // ---- snapshot render ----
87
110
 
88
111
  function renderSnapshot() {
@@ -165,7 +188,9 @@ function renderConfig() {
165
188
  $('#cfgClaudeCommand').value = state.config.claudeCommand || 'claude';
166
189
  $('#cfgCommandShell').value = state.config.commandShell || 'pwsh';
167
190
  $('#cfgAutoFocus').checked = state.config.autoFocusOnLaunch !== false;
168
- $('#cfgAutoOpenBrowser').checked = state.config.autoOpenBrowser !== false;
191
+ $('#cfgBrowserMode').value =
192
+ state.config.browserMode ||
193
+ (state.config.autoOpenBrowser === false ? 'none' : 'app');
169
194
  const termSel = $('#cfgTerminal');
170
195
  termSel.innerHTML = (state.terminals || []).map((t) =>
171
196
  `<option value="${escapeHtml(t.name)}" ${t.name === state.config.terminal ? 'selected' : ''}>${escapeHtml(t.name)} (${escapeHtml(t.processName)})</option>`
@@ -205,7 +230,7 @@ function readConfigFromForm() {
205
230
  terminal: $('#cfgTerminal').value || 'wt',
206
231
  commandShell: $('#cfgCommandShell').value || 'pwsh',
207
232
  autoFocusOnLaunch: $('#cfgAutoFocus').checked,
208
- autoOpenBrowser: $('#cfgAutoOpenBrowser').checked,
233
+ browserMode: $('#cfgBrowserMode').value || 'app',
209
234
  finderPrompt: $('#cfgFinderPrompt').value,
210
235
  repos,
211
236
  };
@@ -219,6 +244,12 @@ async function loadSessions() {
219
244
  renderSessions();
220
245
  }
221
246
 
247
+ async function loadRecent() {
248
+ const r = await api('GET', '/api/sessions/recent?limit=50');
249
+ state.recent = r.recent;
250
+ renderRecent();
251
+ }
252
+
222
253
  async function loadConfig() {
223
254
  const [cfg, terminals] = await Promise.all([
224
255
  api('GET', '/api/config'),
@@ -247,7 +278,7 @@ async function loadWorkspaces() {
247
278
  }
248
279
 
249
280
  async function refreshAll() {
250
- await Promise.all([loadSessions(), loadSnapshot(), loadWorkspaces()]);
281
+ await Promise.all([loadSessions(), loadRecent(), loadSnapshot(), loadWorkspaces()]);
251
282
  }
252
283
 
253
284
  // ---- event wiring ----
@@ -296,6 +327,24 @@ function wireUp() {
296
327
  }
297
328
  });
298
329
 
330
+ $('#recentTable').addEventListener('click', async (ev) => {
331
+ const btn = ev.target.closest('button[data-continue]');
332
+ if (!btn) return;
333
+ const sessionId = btn.dataset.continue;
334
+ const cwd = btn.dataset.cwd;
335
+ btn.disabled = true;
336
+ try {
337
+ await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
338
+ toast(`continuing ${sessionId.slice(0, 8)}…`);
339
+ setTimeout(() => loadSessions().catch(() => {}), 3000);
340
+ setTimeout(() => loadRecent().catch(() => {}), 4000);
341
+ } catch (e) {
342
+ toast(e.message, 'error');
343
+ } finally {
344
+ btn.disabled = false;
345
+ }
346
+ });
347
+
299
348
  $('#finderBtn').onclick = async () => {
300
349
  try {
301
350
  await api('POST', '/api/sessions/finder');
@@ -406,6 +455,7 @@ function startAutoRefresh() {
406
455
  stopAutoRefresh();
407
456
  state.autoTimer = setInterval(() => {
408
457
  loadSessions().catch(() => {});
458
+ loadRecent().catch(() => {});
409
459
  loadSnapshot().catch(() => {});
410
460
  }, 5000);
411
461
  }
package/public/index.html CHANGED
@@ -49,6 +49,28 @@
49
49
  </div>
50
50
  </section>
51
51
 
52
+ <section class="panel">
53
+ <header class="panel-header">
54
+ <h2>recently closed</h2>
55
+ <span id="recentMeta" class="muted"></span>
56
+ </header>
57
+ <div class="table-wrap">
58
+ <table id="recentTable" class="data-table">
59
+ <thead>
60
+ <tr>
61
+ <th>title</th>
62
+ <th>cwd</th>
63
+ <th>branch</th>
64
+ <th>updated</th>
65
+ <th>started</th>
66
+ <th></th>
67
+ </tr>
68
+ </thead>
69
+ <tbody></tbody>
70
+ </table>
71
+ </div>
72
+ </section>
73
+
52
74
  <section class="panel">
53
75
  <header class="panel-header">
54
76
  <h2>snapshot</h2>
@@ -123,9 +145,12 @@
123
145
  <input id="cfgAutoFocus" type="checkbox" />
124
146
  <span style="color: var(--text);">auto-focus newly launched window</span>
125
147
  </label>
126
- <label class="full" style="flex-direction: row; align-items: center; gap: 8px;">
127
- <input id="cfgAutoOpenBrowser" type="checkbox" />
128
- <span style="color: var(--text);">auto-open this UI in browser on server start</span>
148
+ <label>browser open mode (on server start)
149
+ <select id="cfgBrowserMode" class="select">
150
+ <option value="app">app Edge/Chrome chromeless window</option>
151
+ <option value="tab">tab — default browser, normal tab</option>
152
+ <option value="none">off — don't open anything</option>
153
+ </select>
129
154
  </label>
130
155
  <label class="full">finder prompt
131
156
  <textarea id="cfgFinderPrompt" rows="3"></textarea>
package/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const path = require('node:path');
5
5
  const express = require('express');
6
6
 
7
- const { listSessions } = require('./lib/sessions');
7
+ const { listSessions, listRecentSessions } = require('./lib/sessions');
8
8
  const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
9
9
  const {
10
10
  saveSnapshot,
@@ -26,6 +26,7 @@ const {
26
26
  } = require('./lib/launcher');
27
27
  const {
28
28
  focusByPid,
29
+ focusBySession,
29
30
  snapshotWindowsOf,
30
31
  focusNewlyOpenedHwnd,
31
32
  } = require('./lib/focus');
@@ -61,6 +62,14 @@ app.get('/api/sessions', asyncH(async (_req, res) => {
61
62
  res.json({ sessions, takenAt: Date.now() });
62
63
  }));
63
64
 
65
+ app.get('/api/sessions/recent', asyncH(async (req, res) => {
66
+ const limit = Math.min(200, Number(req.query.limit) || 50);
67
+ const live = await listSessions();
68
+ const excludeIds = new Set(live.map((s) => s.sessionId));
69
+ const recent = await listRecentSessions({ limit, excludeIds });
70
+ res.json({ recent, takenAt: Date.now() });
71
+ }));
72
+
64
73
  // ---- config ----
65
74
 
66
75
  app.get('/api/config', asyncH(async (_req, res) => {
@@ -296,8 +305,13 @@ app.post('/api/sessions/:sessionId/focus', asyncH(async (req, res) => {
296
305
  const sessions = await listSessions();
297
306
  const s = sessions.find((x) => x.sessionId === sessionId);
298
307
  if (!s) return res.status(404).json({ error: `session ${sessionId} not live` });
299
- const result = await focusByPid(s.pid);
300
- res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd }, ...result });
308
+ const result = await focusBySession({
309
+ pid: s.pid,
310
+ sessionId: s.sessionId,
311
+ title: s.title,
312
+ cwd: s.cwd,
313
+ });
314
+ res.json({ session: { pid: s.pid, sessionId: s.sessionId, cwd: s.cwd, title: s.title }, ...result });
301
315
  }));
302
316
 
303
317
  // ---- terminal kinds ----
@@ -342,12 +356,53 @@ function listenWithFallback(preferred) {
342
356
  });
343
357
  }
344
358
 
345
- function openInBrowser(url) {
346
- if (process.platform !== 'win32') return;
359
+ function findAppModeBrowser() {
360
+ const candidates = [
361
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
362
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
363
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
364
+ process.env.LOCALAPPDATA &&
365
+ path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
366
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
367
+ ].filter(Boolean);
368
+ const fs = require('node:fs');
369
+ for (const p of candidates) {
370
+ if (fs.existsSync(p)) return p;
371
+ }
372
+ return null;
373
+ }
374
+
375
+ function openInBrowser(url, mode) {
376
+ if (process.platform !== 'win32' || mode === 'none') return;
347
377
  const { spawn } = require('node:child_process');
348
- // cmd's `start` builtin opens URL in the default browser. The empty "" is
349
- // the window-title slot for `start`, otherwise `<url>` would be eaten as
350
- // the title when it has spaces.
378
+ const fs = require('node:fs');
379
+
380
+ if (mode === 'app') {
381
+ const exe = findAppModeBrowser();
382
+ if (exe) {
383
+ // Per-ccsm profile dir so we don't get the "already running, --app
384
+ // ignored" merge behavior of Edge/Chrome when the user has a normal
385
+ // window open. Lives under DATA_DIR so it's tidied with the rest.
386
+ const profileDir = path.join(DATA_DIR, 'browser-profile');
387
+ fs.mkdirSync(profileDir, { recursive: true });
388
+ const child = spawn(
389
+ exe,
390
+ [
391
+ `--app=${url}`,
392
+ `--user-data-dir=${profileDir}`,
393
+ '--window-size=1400,1000',
394
+ '--no-first-run',
395
+ '--no-default-browser-check',
396
+ ],
397
+ { detached: true, stdio: 'ignore' }
398
+ );
399
+ child.unref();
400
+ return;
401
+ }
402
+ console.log('[ccsm] no Edge/Chrome found for app mode, falling back to default browser');
403
+ }
404
+
405
+ // mode === 'tab' (or app-mode fallback)
351
406
  const child = spawn('cmd.exe', ['/c', 'start', '', url], {
352
407
  detached: true,
353
408
  stdio: 'ignore',
@@ -364,7 +419,8 @@ function openInBrowser(url) {
364
419
  console.log(`data dir: ${DATA_DIR}`);
365
420
  console.log(`work dir: ${cfg.workDir}`);
366
421
  console.log(`terminal: ${cfg.terminal} · ${cfg.claudeCommand}${cfg.terminal === 'wt' ? ` (via ${cfg.commandShell})` : ''}`);
367
- if (cfg.autoOpenBrowser !== false) openInBrowser(url);
422
+ const mode = cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app');
423
+ openInBrowser(url, mode);
368
424
  startSnapshotLoop();
369
425
  })().catch((err) => {
370
426
  console.error('startup failed:', err);