@bakapiano/ccsm 0.4.0 → 0.6.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 +58 -3
- package/lib/config.js +1 -0
- package/lib/favorites.js +73 -0
- package/lib/focus.js +90 -14
- package/lib/labels.js +49 -0
- package/lib/sessions.js +48 -8
- package/lib/workspace.js +8 -4
- package/package.json +1 -1
- package/public/app.js +1052 -302
- package/public/favicon.svg +18 -0
- package/public/index.html +481 -159
- package/public/styles.css +1628 -125
- package/server.js +87 -10
package/CLAUDE.md
CHANGED
|
@@ -34,6 +34,7 @@ D:\ccsm\
|
|
|
34
34
|
├── lib\
|
|
35
35
|
│ ├── sessions.js # reads ~/.claude/sessions/*.json + cross-checks live PIDs (tasklist)
|
|
36
36
|
│ │ # + pulls last ai-title from ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
|
|
37
|
+
│ │ # + listRecentSessions(limit, offset) returns paged {recent, total}
|
|
37
38
|
│ ├── snapshot.js # save/load/rotate/restore — restore = launch one wt window per session
|
|
38
39
|
│ ├── workspace.js # workspace = subfolder under workDir holding repo clones;
|
|
39
40
|
│ │ # "in use" = any live session's cwd is at/under the workspace path
|
|
@@ -41,6 +42,7 @@ D:\ccsm\
|
|
|
41
42
|
│ │ # path.resolve()s cwd; throws if cwd doesn't exist
|
|
42
43
|
│ ├── focus.js # PowerShell + Win32 — listWindowsOf, focusByHwnd, focusByPid,
|
|
43
44
|
│ │ # focusNewlyOpenedHwnd (HWND-diff for auto-focus on launch)
|
|
45
|
+
│ ├── favorites.js # user-pinned sessions, ~/.ccsm/favorites.json keyed by sessionId
|
|
44
46
|
│ └── config.js # loadConfig/saveConfig with defaults
|
|
45
47
|
├── public\
|
|
46
48
|
│ ├── index.html, app.js, styles.css # vanilla, auto-refresh every 5s
|
|
@@ -49,7 +51,9 @@ D:\ccsm\
|
|
|
49
51
|
~/.ccsm/ # or $CCSM_HOME
|
|
50
52
|
├── config.json # source of truth
|
|
51
53
|
├── snapshot.json # latest snapshot, rewritten every 60s
|
|
52
|
-
|
|
54
|
+
├── snapshots/ # rotating history (default keep=30)
|
|
55
|
+
├── favorites.json # { [sessionId]: { sessionId, cwd, title, gitBranch, addedAt } }
|
|
56
|
+
└── browser-profile/ # Edge/Chrome --user-data-dir when browserMode=app
|
|
53
57
|
```
|
|
54
58
|
|
|
55
59
|
On first run, if a legacy `<repo>/data/` directory exists and `~/.ccsm/` is empty, `lib/config.js` copies the old data over (one-time, idempotent). The legacy dir is left in place — clean up manually after verifying.
|
|
@@ -88,8 +92,11 @@ The alternative ("one repo per workspace") was explicitly rejected — don't ref
|
|
|
88
92
|
| POST | `/api/sessions/new` | body `{repos, workspace?, launch?}` — picks/creates ws, clones missing repos, launches wt |
|
|
89
93
|
| POST | `/api/sessions/finder` | opens a wt with `claude` in `D:\ccsm` and the `finderPrompt` as opening message |
|
|
90
94
|
| POST | `/api/sessions/:id/resume` | body `{cwd}` — launches `wt -d <cwd> claude --resume <id>` |
|
|
91
|
-
| GET | `/api/sessions/recent` | recently-used sessions
|
|
95
|
+
| GET | `/api/sessions/recent` | recently-used sessions from `~/.claude/projects/*/*.jsonl` mtimes, excluding live ids · query `?limit=15&offset=0` for pagination · returns `{recent, total, limit, offset}` |
|
|
92
96
|
| 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 |
|
|
97
|
+
| GET | `/api/favorites` | array of pinned sessions sorted by `addedAt` desc |
|
|
98
|
+
| POST | `/api/favorites/:id` | star a session; body `{cwd, title, gitBranch?, label?}` (snapshot of current row data so the favorite stays meaningful after the jsonl is gone) |
|
|
99
|
+
| DELETE | `/api/favorites/:id` | unstar |
|
|
93
100
|
| GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
|
|
94
101
|
| GET | `/api/health` | sanity ping |
|
|
95
102
|
|
|
@@ -130,10 +137,58 @@ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends again
|
|
|
130
137
|
|
|
131
138
|
**`projectSlugForCwd`.** The path-to-slug mapping is `cwd.replace(/[:\\]/g, '-')`, e.g. `D:\ccsm` → `D--ccsm`, `C:\Users\foo` → `C--Users-foo`, `D:\` → `D--`.
|
|
132
139
|
|
|
140
|
+
## Frontend design language
|
|
141
|
+
|
|
142
|
+
The UI deliberately copies **claude.ai's** calm light aesthetic — warm cream surfaces, generous spacing, soft borders, single Claude-orange accent. Don't dark-mode-ify or chrome-ify.
|
|
143
|
+
|
|
144
|
+
**Palette** (CSS vars in `public/styles.css`):
|
|
145
|
+
- `--bg` `#faf9f5` warm cream page background
|
|
146
|
+
- `--bg-elev` `#ffffff` card surfaces
|
|
147
|
+
- `--sidebar-bg` `#f3f0e8` slightly darker cream for the rail
|
|
148
|
+
- `--border` `#e8e3d5` hairlines
|
|
149
|
+
- `--ink` `#1a1815` body text (warm near-black)
|
|
150
|
+
- `--ink-mid` `#534e44` secondary
|
|
151
|
+
- `--ink-muted` `#8a8475` meta
|
|
152
|
+
- `--accent` `#c45f3f` Claude warm orange — for primary actions, focus rings, active states ONLY
|
|
153
|
+
- Status: green `#4a8a4a` idle · yellow `#c4892b` busy (pulsing) · red `#b73f3f` danger
|
|
154
|
+
|
|
155
|
+
**Type**:
|
|
156
|
+
- Body / headings: **Geist** (Google Fonts, 300–700). No Fraunces / no italic display.
|
|
157
|
+
- Mono: **JetBrains Mono** for paths, PIDs, sessionIds, branch tags, timestamps in meta.
|
|
158
|
+
- Always `font-variant-numeric: tabular-nums` on numeric cells.
|
|
159
|
+
|
|
160
|
+
**Layout**:
|
|
161
|
+
- **Sidebar** (collapsible, ~232px ↔ ~60px, state in `localStorage["ccsm.sidebar-collapsed"]`):
|
|
162
|
+
- top: brand mark (orange rounded square) + `ccsm.` wordmark
|
|
163
|
+
- mid: 3 nav items (Sessions / Launch / Configure) with stroke icons + label + optional badge
|
|
164
|
+
- divider + utility items (Refresh, Ask Claude)
|
|
165
|
+
- footer: collapse toggle (chevron flips on collapse via CSS rotate)
|
|
166
|
+
- **Main column**: page header (title + subtitle + meta row of port/terminal/clock) → content cards → footer status line
|
|
167
|
+
- Cards: `.card` (white, 10px radius, very soft `--shadow`), `.card-head` with title+meta, `.card-body` with optional `.card-body-flush` for tables.
|
|
168
|
+
- Tables: wrapped in `.table-scroll` (`overflow-x: auto`, min-width 760px) so narrow viewports scroll horizontally instead of cramping.
|
|
169
|
+
|
|
170
|
+
**Animation**:
|
|
171
|
+
- **Don't re-animate on refresh.** Rows have a one-shot staggered fade-in animation. `app.js` `markRendered(tableId)` adds `.no-anim` to the tbody after the first render via double `requestAnimationFrame`, so subsequent 5-second auto-refreshes don't restage every row (would strobe).
|
|
172
|
+
- Panel switch: 0.35s `panel-in` fade-up.
|
|
173
|
+
- Tab indicator: orange left bar on active sidebar item (`::before`).
|
|
174
|
+
- Busy status mark: green-pulse via `box-shadow` keyframes.
|
|
175
|
+
|
|
176
|
+
**Star (favorites) UI**:
|
|
177
|
+
- Star button sits **inside the title cell**, right next to the title text (not in its own column). Outline-style by default at 55% opacity; row-hover bumps to 100%; favorited state fills with the accent color.
|
|
178
|
+
- Click is delegated at the table level (`button[data-star]`). Toggle is **optimistic**: `state.favorites` updates and re-renders all 3 tables before the network call returns; failure shows a toast.
|
|
179
|
+
- Backend snapshots `cwd / title / gitBranch` into `favorites.json` so the favorite is still meaningful after the source jsonl is gone.
|
|
180
|
+
|
|
181
|
+
**No emoji in the UI** unless the user typed it (e.g. wt status glyphs in session titles). Use inline SVG icons everywhere (line stroke, 1.5–2px) so they take `currentColor` and live with the type weight.
|
|
182
|
+
|
|
183
|
+
## Lifecycle: server tied to browser window
|
|
184
|
+
|
|
185
|
+
When the user launches via `npx @bakapiano/ccsm` from an interactive terminal (`process.stdout.isTTY === true`) AND `browserMode === 'app'`, the server keeps the spawned Edge/Chrome child handle and listens for its `exit` event. When the user closes the chromeless window, msedge.exe (running with its own `--user-data-dir=<DATA_DIR>/browser-profile` process group) exits, our hook fires `process.exit(0)`, and the terminal returns to a prompt. Headless / `nohup` launches don't get this hook (no TTY) and stay running.
|
|
186
|
+
|
|
133
187
|
## Extending
|
|
134
188
|
|
|
135
189
|
When adding features, the natural extension points:
|
|
136
190
|
- New REST routes: `server.js` (keep them under `/api/*`, use `asyncH` wrapper).
|
|
137
|
-
- Frontend section: add a `<section class="
|
|
191
|
+
- Frontend section: add a `<section class="card">` in `public/index.html` and a render function in `public/app.js`. Use `markRendered(tableId)` after the first render to suppress refresh strobing.
|
|
192
|
+
- Persistent user data: drop a JSON file under `~/.ccsm/` (like `favorites.json`) and wrap with a small lib module — config.js / favorites.js pattern.
|
|
138
193
|
- Workspace lifecycle (delete, rename): `lib/workspace.js`.
|
|
139
194
|
- Different launch modes (e.g., stacked tabs): `lib/launcher.js` — but check first whether the "one window per session" decision still holds.
|
package/lib/config.js
CHANGED
|
@@ -22,6 +22,7 @@ const DEFAULTS = {
|
|
|
22
22
|
terminal: 'wt',
|
|
23
23
|
commandShell: 'pwsh',
|
|
24
24
|
autoFocusOnLaunch: true,
|
|
25
|
+
focusMovesToCenter: false,
|
|
25
26
|
// 'app' — Edge/Chrome --app=<url> chromeless window (looks like a desktop app)
|
|
26
27
|
// 'tab' — open in default browser as a normal tab
|
|
27
28
|
// 'none' — don't open anything
|
package/lib/favorites.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// User-pinned ("favorited") sessions. Stored as a JSON object keyed by
|
|
4
|
+
// sessionId at $DATA_DIR/favorites.json. Each entry captures enough
|
|
5
|
+
// metadata (cwd, title, gitBranch) to render the row even after the
|
|
6
|
+
// session's jsonl is gone — the entry is best-effort archival.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs/promises');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { DATA_DIR } = require('./config');
|
|
11
|
+
|
|
12
|
+
const FAVORITES_PATH = path.join(DATA_DIR, 'favorites.json');
|
|
13
|
+
|
|
14
|
+
async function loadFavorites() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await fs.readFile(FAVORITES_PATH, 'utf8');
|
|
17
|
+
const obj = JSON.parse(raw);
|
|
18
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e.code === 'ENOENT') return {};
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function saveFavorites(favs) {
|
|
26
|
+
await fs.writeFile(FAVORITES_PATH, JSON.stringify(favs, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function listFavorites() {
|
|
30
|
+
const favs = await loadFavorites();
|
|
31
|
+
return Object.values(favs).sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function addFavorite(sessionId, info = {}) {
|
|
35
|
+
if (!sessionId) throw new Error('addFavorite: sessionId required');
|
|
36
|
+
const favs = await loadFavorites();
|
|
37
|
+
const existing = favs[sessionId];
|
|
38
|
+
favs[sessionId] = existing
|
|
39
|
+
? { ...existing, ...info, sessionId }
|
|
40
|
+
: {
|
|
41
|
+
sessionId,
|
|
42
|
+
cwd: info.cwd || null,
|
|
43
|
+
title: info.title || null,
|
|
44
|
+
gitBranch: info.gitBranch || null,
|
|
45
|
+
label: info.label || null,
|
|
46
|
+
addedAt: Date.now(),
|
|
47
|
+
};
|
|
48
|
+
await saveFavorites(favs);
|
|
49
|
+
return favs[sessionId];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function removeFavorite(sessionId) {
|
|
53
|
+
const favs = await loadFavorites();
|
|
54
|
+
if (!(sessionId in favs)) return false;
|
|
55
|
+
delete favs[sessionId];
|
|
56
|
+
await saveFavorites(favs);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function hasFavorite(sessionId) {
|
|
61
|
+
const favs = await loadFavorites();
|
|
62
|
+
return sessionId in favs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
loadFavorites,
|
|
67
|
+
saveFavorites,
|
|
68
|
+
listFavorites,
|
|
69
|
+
addFavorite,
|
|
70
|
+
removeFavorite,
|
|
71
|
+
hasFavorite,
|
|
72
|
+
FAVORITES_PATH,
|
|
73
|
+
};
|
package/lib/focus.js
CHANGED
|
@@ -32,6 +32,7 @@ using System;
|
|
|
32
32
|
using System.Collections.Generic;
|
|
33
33
|
using System.Runtime.InteropServices;
|
|
34
34
|
using System.Text;
|
|
35
|
+
using System.Threading;
|
|
35
36
|
public class CcsmWin {
|
|
36
37
|
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
37
38
|
[DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr h, bool t);
|
|
@@ -46,8 +47,27 @@ public class CcsmWin {
|
|
|
46
47
|
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr p);
|
|
47
48
|
[DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int max);
|
|
48
49
|
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr h);
|
|
50
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT rect);
|
|
51
|
+
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
|
52
|
+
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint);
|
|
53
|
+
[DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
|
|
54
|
+
[DllImport("user32.dll")] public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO mi);
|
|
55
|
+
[DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int hgt, bool repaint);
|
|
49
56
|
public delegate bool EnumWindowsProc(IntPtr h, IntPtr p);
|
|
50
57
|
|
|
58
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
59
|
+
public struct RECT { public int Left, Top, Right, Bottom; }
|
|
60
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
61
|
+
public struct POINT { public int X, Y; }
|
|
62
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
63
|
+
public struct MONITORINFO {
|
|
64
|
+
public uint cbSize;
|
|
65
|
+
public RECT rcMonitor;
|
|
66
|
+
public RECT rcWork;
|
|
67
|
+
public uint dwFlags;
|
|
68
|
+
}
|
|
69
|
+
const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
|
70
|
+
|
|
51
71
|
public static List<object> EnumVisibleTopLevel() {
|
|
52
72
|
var results = new List<object>();
|
|
53
73
|
EnumWindows((h, l) => {
|
|
@@ -64,7 +84,54 @@ public class CcsmWin {
|
|
|
64
84
|
return results;
|
|
65
85
|
}
|
|
66
86
|
|
|
67
|
-
|
|
87
|
+
// SWP flags
|
|
88
|
+
const uint SWP_NOSIZE = 0x0001;
|
|
89
|
+
const uint SWP_NOZORDER = 0x0004;
|
|
90
|
+
const uint SWP_NOACTIVATE = 0x0010;
|
|
91
|
+
const uint SWP_ASYNCWINDOWPOS = 0x4000;
|
|
92
|
+
const uint SWP_FLAGS = SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS;
|
|
93
|
+
|
|
94
|
+
// Horizontal shake — small left/right offsets so the user's eye snaps
|
|
95
|
+
// to the activated window. ~220ms total.
|
|
96
|
+
public static void Jiggle(IntPtr h) {
|
|
97
|
+
RECT rc;
|
|
98
|
+
if (!GetWindowRect(h, out rc)) return;
|
|
99
|
+
int x = rc.Left;
|
|
100
|
+
int y = rc.Top;
|
|
101
|
+
int[] dx = new int[] { 8, -8, 6, -6, 0 };
|
|
102
|
+
foreach (int d in dx) {
|
|
103
|
+
SetWindowPos(h, IntPtr.Zero, x + d, y, 0, 0, SWP_FLAGS);
|
|
104
|
+
Thread.Sleep(38);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Move the window to the center of whichever monitor the cursor is on.
|
|
109
|
+
// Preserves window size. Returns true if it moved.
|
|
110
|
+
public static bool MoveToCursorScreenCenter(IntPtr h) {
|
|
111
|
+
RECT wr;
|
|
112
|
+
if (!GetWindowRect(h, out wr)) return false;
|
|
113
|
+
int ww = wr.Right - wr.Left;
|
|
114
|
+
int wh = wr.Bottom - wr.Top;
|
|
115
|
+
|
|
116
|
+
POINT cursor;
|
|
117
|
+
if (!GetCursorPos(out cursor)) return false;
|
|
118
|
+
IntPtr hMon = MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST);
|
|
119
|
+
if (hMon == IntPtr.Zero) return false;
|
|
120
|
+
|
|
121
|
+
MONITORINFO mi = new MONITORINFO();
|
|
122
|
+
mi.cbSize = (uint)Marshal.SizeOf(typeof(MONITORINFO));
|
|
123
|
+
if (!GetMonitorInfo(hMon, ref mi)) return false;
|
|
124
|
+
|
|
125
|
+
// Use the work area (excludes taskbar) so a centered window isn't
|
|
126
|
+
// pushed under the taskbar.
|
|
127
|
+
int sw = mi.rcWork.Right - mi.rcWork.Left;
|
|
128
|
+
int sh = mi.rcWork.Bottom - mi.rcWork.Top;
|
|
129
|
+
int nx = mi.rcWork.Left + (sw - ww) / 2;
|
|
130
|
+
int ny = mi.rcWork.Top + (sh - wh) / 2;
|
|
131
|
+
return MoveWindow(h, nx, ny, ww, wh, true);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public static bool Activate(IntPtr h, bool moveToCenter) {
|
|
68
135
|
if (IsIconic(h)) ShowWindowAsync(h, 9);
|
|
69
136
|
keybd_event(0x12, 0, 0, UIntPtr.Zero);
|
|
70
137
|
keybd_event(0x12, 0, 0x0002, UIntPtr.Zero);
|
|
@@ -77,6 +144,8 @@ public class CcsmWin {
|
|
|
77
144
|
bool ok = SetForegroundWindow(h);
|
|
78
145
|
SwitchToThisWindow(h, true);
|
|
79
146
|
if (attached) AttachThreadInput(c, t, false);
|
|
147
|
+
if (moveToCenter) MoveToCursorScreenCenter(h);
|
|
148
|
+
Jiggle(h);
|
|
80
149
|
return ok;
|
|
81
150
|
}
|
|
82
151
|
}
|
|
@@ -84,6 +153,7 @@ public class CcsmWin {
|
|
|
84
153
|
|
|
85
154
|
$mode = $env:CCSM_FOCUS_MODE
|
|
86
155
|
$arg = $env:CCSM_FOCUS_ARG
|
|
156
|
+
$moveToCenter = $env:CCSM_FOCUS_CENTER -eq '1'
|
|
87
157
|
|
|
88
158
|
if ($mode -eq 'list') {
|
|
89
159
|
$procName = $arg -replace '\.exe$',''
|
|
@@ -109,7 +179,7 @@ if ($mode -eq 'list') {
|
|
|
109
179
|
if ($mode -eq 'focus-hwnd') {
|
|
110
180
|
$hwndInt = [int64]$arg
|
|
111
181
|
$hwnd = [IntPtr]::new($hwndInt)
|
|
112
|
-
$ok = [CcsmWin]::Activate($hwnd)
|
|
182
|
+
$ok = [CcsmWin]::Activate($hwnd, $moveToCenter)
|
|
113
183
|
Write-Output (ConvertTo-Json @{ ok = $true; activated = $ok; hwnd = $hwndInt } -Compress)
|
|
114
184
|
exit 0
|
|
115
185
|
}
|
|
@@ -132,7 +202,7 @@ if ($mode -eq 'focus-pid') {
|
|
|
132
202
|
Write-Output (ConvertTo-Json @{ ok = $false; error = "no window handle found for pid $arg"; chain = $chain } -Compress -Depth 5)
|
|
133
203
|
exit 1
|
|
134
204
|
}
|
|
135
|
-
$activated = [CcsmWin]::Activate($hwnd)
|
|
205
|
+
$activated = [CcsmWin]::Activate($hwnd, $moveToCenter)
|
|
136
206
|
Write-Output (ConvertTo-Json @{
|
|
137
207
|
ok = $true; activated = $activated
|
|
138
208
|
hwnd = $hwnd.ToInt64()
|
|
@@ -148,7 +218,7 @@ Write-Output (ConvertTo-Json @{ ok = $false; error = "unknown CCSM_FOCUS_MODE: $
|
|
|
148
218
|
exit 1
|
|
149
219
|
`;
|
|
150
220
|
|
|
151
|
-
function runPsHelper(mode, arg) {
|
|
221
|
+
function runPsHelper(mode, arg, opts = {}) {
|
|
152
222
|
return new Promise((resolve, reject) => {
|
|
153
223
|
const encoded = Buffer.from(FOCUS_HELPER_PS, 'utf16le').toString('base64');
|
|
154
224
|
const child = spawn(
|
|
@@ -156,7 +226,12 @@ function runPsHelper(mode, arg) {
|
|
|
156
226
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
157
227
|
{
|
|
158
228
|
windowsHide: true,
|
|
159
|
-
env: {
|
|
229
|
+
env: {
|
|
230
|
+
...process.env,
|
|
231
|
+
CCSM_FOCUS_MODE: mode,
|
|
232
|
+
CCSM_FOCUS_ARG: String(arg),
|
|
233
|
+
CCSM_FOCUS_CENTER: opts.moveToCenter ? '1' : '0',
|
|
234
|
+
},
|
|
160
235
|
}
|
|
161
236
|
);
|
|
162
237
|
let out = '';
|
|
@@ -186,10 +261,10 @@ function runPsHelper(mode, arg) {
|
|
|
186
261
|
|
|
187
262
|
// ---- public API ----
|
|
188
263
|
|
|
189
|
-
async function focusByPid(pid) {
|
|
264
|
+
async function focusByPid(pid, opts = {}) {
|
|
190
265
|
const n = Number(pid);
|
|
191
266
|
if (!Number.isInteger(n) || n <= 0) throw new Error(`focusByPid: invalid pid ${pid}`);
|
|
192
|
-
return await runPsHelper('focus-pid', n);
|
|
267
|
+
return await runPsHelper('focus-pid', n, opts);
|
|
193
268
|
}
|
|
194
269
|
|
|
195
270
|
// Strip the leading wt status glyph + whitespace ("✳ ", "⠐ ", "⠠ " etc) so
|
|
@@ -205,9 +280,10 @@ function cleanWtTitle(t) {
|
|
|
205
280
|
// window regardless of which tab the session is actually in. Instead we
|
|
206
281
|
// list all wt windows and match by tab title against the session's AI
|
|
207
282
|
// title / cwd basename.
|
|
208
|
-
async function focusBySession({ pid, sessionId, title, cwd }) {
|
|
283
|
+
async function focusBySession({ pid, sessionId, title, cwd, moveToCenter = false }) {
|
|
209
284
|
const procName = 'WindowsTerminal.exe';
|
|
210
285
|
const cands = await listWindowsOf(procName);
|
|
286
|
+
const opts = { moveToCenter };
|
|
211
287
|
|
|
212
288
|
const cleanedTitle = title ? cleanWtTitle(title) : '';
|
|
213
289
|
const cwdBase = cwd ? require('node:path').basename(cwd) : '';
|
|
@@ -216,7 +292,7 @@ async function focusBySession({ pid, sessionId, title, cwd }) {
|
|
|
216
292
|
if (cleanedTitle) {
|
|
217
293
|
const exact = cands.filter((w) => cleanWtTitle(w.title) === cleanedTitle);
|
|
218
294
|
if (exact.length === 1) {
|
|
219
|
-
const r = await focusByHwnd(exact[0].hwnd);
|
|
295
|
+
const r = await focusByHwnd(exact[0].hwnd, opts);
|
|
220
296
|
return { ...r, matchedBy: 'title-exact', hwnd: exact[0].hwnd, windowTitle: exact[0].title };
|
|
221
297
|
}
|
|
222
298
|
}
|
|
@@ -225,7 +301,7 @@ async function focusBySession({ pid, sessionId, title, cwd }) {
|
|
|
225
301
|
if (cleanedTitle) {
|
|
226
302
|
const subs = cands.filter((w) => w.title.includes(cleanedTitle));
|
|
227
303
|
if (subs.length === 1) {
|
|
228
|
-
const r = await focusByHwnd(subs[0].hwnd);
|
|
304
|
+
const r = await focusByHwnd(subs[0].hwnd, opts);
|
|
229
305
|
return { ...r, matchedBy: 'title-substring', hwnd: subs[0].hwnd, windowTitle: subs[0].title };
|
|
230
306
|
}
|
|
231
307
|
}
|
|
@@ -234,7 +310,7 @@ async function focusBySession({ pid, sessionId, title, cwd }) {
|
|
|
234
310
|
if (cwdBase) {
|
|
235
311
|
const byCwd = cands.filter((w) => w.title.includes(cwdBase));
|
|
236
312
|
if (byCwd.length === 1) {
|
|
237
|
-
const r = await focusByHwnd(byCwd[0].hwnd);
|
|
313
|
+
const r = await focusByHwnd(byCwd[0].hwnd, opts);
|
|
238
314
|
return { ...r, matchedBy: 'cwd-basename', hwnd: byCwd[0].hwnd, windowTitle: byCwd[0].title };
|
|
239
315
|
}
|
|
240
316
|
}
|
|
@@ -243,14 +319,14 @@ async function focusBySession({ pid, sessionId, title, cwd }) {
|
|
|
243
319
|
// canonical MainWindowHandle — may be the wrong window when wt is
|
|
244
320
|
// multi-window single-process, but better than nothing).
|
|
245
321
|
if (pid) {
|
|
246
|
-
const r = await focusByPid(pid);
|
|
322
|
+
const r = await focusByPid(pid, opts);
|
|
247
323
|
return { ...r, matchedBy: 'pid-fallback', ambiguous: true };
|
|
248
324
|
}
|
|
249
325
|
return { ok: false, error: 'no match by title/cwd and no pid given', matchedBy: 'none' };
|
|
250
326
|
}
|
|
251
327
|
|
|
252
|
-
async function focusByHwnd(hwnd) {
|
|
253
|
-
return await runPsHelper('focus-hwnd', hwnd);
|
|
328
|
+
async function focusByHwnd(hwnd, opts = {}) {
|
|
329
|
+
return await runPsHelper('focus-hwnd', hwnd, opts);
|
|
254
330
|
}
|
|
255
331
|
|
|
256
332
|
async function listWindowsOf(processName) {
|
package/lib/labels.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// User-defined display titles for sessions. Stored as a flat JSON object
|
|
4
|
+
// keyed by sessionId at $DATA_DIR/labels.json. Frontend overlays the label
|
|
5
|
+
// on top of the AI-generated title when rendering live / recent / favorites.
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs/promises');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { DATA_DIR } = require('./config');
|
|
10
|
+
|
|
11
|
+
const LABELS_PATH = path.join(DATA_DIR, 'labels.json');
|
|
12
|
+
const MAX_LEN = 200;
|
|
13
|
+
|
|
14
|
+
async function loadLabels() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = await fs.readFile(LABELS_PATH, 'utf8');
|
|
17
|
+
const obj = JSON.parse(raw);
|
|
18
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e.code === 'ENOENT') return {};
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function saveLabels(labels) {
|
|
26
|
+
await fs.writeFile(LABELS_PATH, JSON.stringify(labels, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function setLabel(sessionId, label) {
|
|
30
|
+
if (!sessionId) throw new Error('setLabel: sessionId required');
|
|
31
|
+
const trimmed = String(label || '').trim().slice(0, MAX_LEN);
|
|
32
|
+
if (!trimmed) {
|
|
33
|
+
return removeLabel(sessionId);
|
|
34
|
+
}
|
|
35
|
+
const labels = await loadLabels();
|
|
36
|
+
labels[sessionId] = trimmed;
|
|
37
|
+
await saveLabels(labels);
|
|
38
|
+
return trimmed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function removeLabel(sessionId) {
|
|
42
|
+
const labels = await loadLabels();
|
|
43
|
+
if (!(sessionId in labels)) return false;
|
|
44
|
+
delete labels[sessionId];
|
|
45
|
+
await saveLabels(labels);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { loadLabels, saveLabels, setLabel, removeLabel, LABELS_PATH };
|
package/lib/sessions.js
CHANGED
|
@@ -161,10 +161,8 @@ async function readJsonlMetadata(jsonlPath) {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
// `excludeIds` from listSessions()). Sorted by file mtime desc.
|
|
167
|
-
async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
|
|
164
|
+
// Cheap enumeration of jsonl session files — file stats only, no content read.
|
|
165
|
+
async function enumerateRecentCandidates({ excludeIds = null } = {}) {
|
|
168
166
|
let projectDirs;
|
|
169
167
|
try {
|
|
170
168
|
projectDirs = await fs.readdir(PROJECTS_DIR);
|
|
@@ -200,10 +198,19 @@ async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
|
|
|
200
198
|
}
|
|
201
199
|
|
|
202
200
|
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
203
|
-
|
|
201
|
+
return candidates;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Paginated, sorted by mtime desc. Returns { recent, total } so callers can
|
|
205
|
+
// render pagination controls. Cheap stat-only enumeration happens for ALL
|
|
206
|
+
// candidates; expensive jsonl-content reads only on the page slice.
|
|
207
|
+
async function listRecentSessions({ limit = 50, offset = 0, excludeIds = null } = {}) {
|
|
208
|
+
const candidates = await enumerateRecentCandidates({ excludeIds });
|
|
209
|
+
const total = candidates.length;
|
|
210
|
+
const page = candidates.slice(offset, offset + limit);
|
|
204
211
|
|
|
205
212
|
const results = await Promise.all(
|
|
206
|
-
|
|
213
|
+
page.map(async (c) => {
|
|
207
214
|
const meta = await readJsonlMetadata(c.jsonlPath);
|
|
208
215
|
const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
|
|
209
216
|
return {
|
|
@@ -218,13 +225,46 @@ async function listRecentSessions({ limit = 50, excludeIds = null } = {}) {
|
|
|
218
225
|
})
|
|
219
226
|
);
|
|
220
227
|
|
|
221
|
-
// Drop entries with no cwd — can't resume without one
|
|
222
|
-
|
|
228
|
+
// Drop entries with no cwd — can't resume without one. Adjust total
|
|
229
|
+
// accordingly is tricky (would need to enrich all), so we don't — the
|
|
230
|
+
// total is the upper bound of resumable + non-resumable.
|
|
231
|
+
return { recent: results.filter((r) => r.cwd), total };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Look up metadata for a specific sessionId by scanning the projects dir.
|
|
235
|
+
// Used by /api/favorites to display rich metadata for archived sessions.
|
|
236
|
+
async function findSessionMetadata(sessionId) {
|
|
237
|
+
let projectDirs;
|
|
238
|
+
try {
|
|
239
|
+
projectDirs = await fs.readdir(PROJECTS_DIR);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
for (const slugDir of projectDirs) {
|
|
244
|
+
const fullPath = path.join(PROJECTS_DIR, slugDir, `${sessionId}.jsonl`);
|
|
245
|
+
try {
|
|
246
|
+
const stat = await fs.stat(fullPath);
|
|
247
|
+
if (stat.size === 0) continue;
|
|
248
|
+
const meta = await readJsonlMetadata(fullPath);
|
|
249
|
+
const firstTs = meta.firstTimestamp ? Date.parse(meta.firstTimestamp) : null;
|
|
250
|
+
return {
|
|
251
|
+
sessionId,
|
|
252
|
+
cwd: meta.cwd || null,
|
|
253
|
+
title: meta.title || null,
|
|
254
|
+
gitBranch: meta.gitBranch || null,
|
|
255
|
+
updatedAt: stat.mtimeMs,
|
|
256
|
+
startedAt: Number.isFinite(firstTs) ? firstTs : null,
|
|
257
|
+
jsonlPath: fullPath,
|
|
258
|
+
};
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
223
262
|
}
|
|
224
263
|
|
|
225
264
|
module.exports = {
|
|
226
265
|
listSessions,
|
|
227
266
|
listRecentSessions,
|
|
267
|
+
findSessionMetadata,
|
|
228
268
|
projectSlugForCwd,
|
|
229
269
|
getLiveClaudePids,
|
|
230
270
|
SESSIONS_DIR,
|
package/lib/workspace.js
CHANGED
|
@@ -191,10 +191,14 @@ async function cloneRepoInto({ workspacePath, repo, onProgress, onLine }) {
|
|
|
191
191
|
`Target ${target} exists but is not a git clone — refusing to overwrite`
|
|
192
192
|
);
|
|
193
193
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
// -c core.longpaths=true defeats Windows' default 260-char MAX_PATH so deep
|
|
195
|
+
// repo trees (e.g. nested doc / .github skill paths) can check out
|
|
196
|
+
// successfully. The flag only applies to this single git invocation.
|
|
197
|
+
await runGit(
|
|
198
|
+
['-c', 'core.longpaths=true', 'clone', '--progress', repo.url, repo.name],
|
|
199
|
+
workspacePath,
|
|
200
|
+
{ onProgress, onLine }
|
|
201
|
+
);
|
|
198
202
|
return { repo: repo.name, action: 'cloned', path: target };
|
|
199
203
|
}
|
|
200
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|