@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 +5 -2
- package/lib/config.js +4 -1
- package/lib/focus.js +58 -0
- package/lib/sessions.js +113 -0
- package/package.json +1 -1
- package/public/app.js +53 -3
- package/public/index.html +28 -3
- package/server.js +65 -9
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
|
-
- `
|
|
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
|
-
|
|
|
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
|
-
|
|
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.
|
|
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
|
-
$('#
|
|
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
|
-
|
|
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
|
|
127
|
-
<
|
|
128
|
-
|
|
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
|
|
300
|
-
|
|
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
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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);
|