@bakapiano/ccsm 0.5.0 → 0.8.3
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/README.md +172 -38
- package/bin/ccsm.js +194 -0
- package/lib/config.js +1 -0
- package/lib/favorites.js +23 -45
- package/lib/focus.js +90 -14
- package/lib/jsonStore.js +60 -0
- package/lib/labels.js +29 -0
- package/lib/webTerminal.js +173 -0
- package/lib/workspace.js +8 -4
- package/package.json +11 -3
- package/public/css/base.css +82 -0
- package/public/css/cards.css +149 -0
- package/public/css/feedback.css +219 -0
- package/public/css/forms.css +282 -0
- package/public/css/layout.css +107 -0
- package/public/css/modal.css +169 -0
- package/public/css/responsive.css +10 -0
- package/public/css/sidebar.css +165 -0
- package/public/css/tables.css +266 -0
- package/public/css/terminals.css +112 -0
- package/public/css/tokens.css +63 -0
- package/public/css/wco.css +70 -0
- package/public/css/widgets.css +204 -0
- package/public/favicon.svg +18 -0
- package/public/index.html +53 -379
- package/public/js/actions.js +87 -0
- package/public/js/api.js +103 -0
- package/public/js/backend.js +28 -0
- package/public/js/components/App.js +45 -0
- package/public/js/components/Card.js +24 -0
- package/public/js/components/DialogHost.js +45 -0
- package/public/js/components/Fab.js +11 -0
- package/public/js/components/FavoritesTable.js +81 -0
- package/public/js/components/Footer.js +12 -0
- package/public/js/components/NewSessionModal.js +142 -0
- package/public/js/components/OfflineBanner.js +52 -0
- package/public/js/components/PageHead.js +33 -0
- package/public/js/components/Pagination.js +27 -0
- package/public/js/components/ProgressList.js +32 -0
- package/public/js/components/RecentTable.js +68 -0
- package/public/js/components/RepoPicker.js +40 -0
- package/public/js/components/ReposEditor.js +74 -0
- package/public/js/components/ServerStatus.js +18 -0
- package/public/js/components/SessionsTable.js +71 -0
- package/public/js/components/Sidebar.js +52 -0
- package/public/js/components/SnapshotPanel.js +77 -0
- package/public/js/components/TerminalView.js +108 -0
- package/public/js/components/TitleCell.js +40 -0
- package/public/js/components/Toast.js +8 -0
- package/public/js/components/WorkspacePicker.js +19 -0
- package/public/js/components/WorkspacesGrid.js +41 -0
- package/public/js/dialog.js +59 -0
- package/public/js/html.js +6 -0
- package/public/js/icons.js +114 -0
- package/public/js/main.js +81 -0
- package/public/js/pages/AboutPage.js +85 -0
- package/public/js/pages/ConfigurePage.js +194 -0
- package/public/js/pages/LaunchPage.js +117 -0
- package/public/js/pages/SessionsPage.js +47 -0
- package/public/js/pages/TerminalsPage.js +74 -0
- package/public/js/state.js +87 -0
- package/public/js/streaming.js +96 -0
- package/public/js/toast.js +14 -0
- package/public/js/util.js +24 -0
- package/public/manifest.webmanifest +14 -0
- package/scripts/install.js +111 -0
- package/scripts/uninstall.js +56 -0
- package/server.js +314 -31
- package/public/app.js +0 -894
- package/public/styles.css +0 -1204
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/jsonStore.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Factory for a keyed-JSON store under $DATA_DIR. Both favorites and labels
|
|
4
|
+
// have the same shape: a JSON object keyed by sessionId, written atomically
|
|
5
|
+
// on each mutation, with ENOENT swallowed to empty.
|
|
6
|
+
//
|
|
7
|
+
// const store = createKeyedJsonStore({ filename: 'foo.json', transformValue: (v) => ... })
|
|
8
|
+
// await store.load() → object
|
|
9
|
+
// await store.set(key, v) → returns the stored value (or null if removed)
|
|
10
|
+
// await store.remove(key) → returns true if it existed
|
|
11
|
+
// await store.list() → array of values
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs/promises');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v }) {
|
|
17
|
+
const filePath = path.join(dataDir, filename);
|
|
18
|
+
|
|
19
|
+
async function load() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
22
|
+
const obj = JSON.parse(raw);
|
|
23
|
+
return obj && typeof obj === 'object' && !Array.isArray(obj) ? obj : {};
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e.code === 'ENOENT') return {};
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function save(map) {
|
|
31
|
+
await fs.writeFile(filePath, JSON.stringify(map, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function set(key, value) {
|
|
35
|
+
if (!key) throw new Error('set: key required');
|
|
36
|
+
const next = transformValue(value, key);
|
|
37
|
+
if (next == null) return remove(key);
|
|
38
|
+
const map = await load();
|
|
39
|
+
map[key] = next;
|
|
40
|
+
await save(map);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function remove(key) {
|
|
45
|
+
const map = await load();
|
|
46
|
+
if (!(key in map)) return false;
|
|
47
|
+
delete map[key];
|
|
48
|
+
await save(map);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function list() {
|
|
53
|
+
const map = await load();
|
|
54
|
+
return Object.values(map);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { load, save, set, remove, list, filePath };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { createKeyedJsonStore };
|
package/lib/labels.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// User-defined display titles for sessions, keyed by sessionId at
|
|
4
|
+
// $DATA_DIR/labels.json. Frontend overlays the label on top of the
|
|
5
|
+
// AI-generated title when rendering live / recent / favorites.
|
|
6
|
+
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { DATA_DIR } = require('./config');
|
|
9
|
+
const { createKeyedJsonStore } = require('./jsonStore');
|
|
10
|
+
|
|
11
|
+
const MAX_LEN = 200;
|
|
12
|
+
|
|
13
|
+
const store = createKeyedJsonStore({
|
|
14
|
+
dataDir: DATA_DIR,
|
|
15
|
+
filename: 'labels.json',
|
|
16
|
+
// Empty / null label triggers a remove via the factory contract.
|
|
17
|
+
transformValue: (v) => {
|
|
18
|
+
const trimmed = String(v || '').trim().slice(0, MAX_LEN);
|
|
19
|
+
return trimmed || null;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
loadLabels: store.load,
|
|
25
|
+
saveLabels: store.save,
|
|
26
|
+
setLabel: store.set,
|
|
27
|
+
removeLabel: store.remove,
|
|
28
|
+
LABELS_PATH: store.filePath,
|
|
29
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ccsm in-process PTY pool. Used by the "web terminal" launch path:
|
|
4
|
+
// claude (or any cmd) runs as a child of ccsm and its stdio is bridged
|
|
5
|
+
// to one or more WebSocket clients via xterm.js in the browser.
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle: a PTY entry is created by spawn(), broadcasts every output
|
|
8
|
+
// chunk to all attached sockets, keeps a rolling history ring so a fresh
|
|
9
|
+
// connection can replay recent output. attach() wires a websocket to an
|
|
10
|
+
// entry, kill() ends a PTY explicitly, list() returns metadata for UI.
|
|
11
|
+
//
|
|
12
|
+
// node-pty is optional (Windows native binary). If it failed to load,
|
|
13
|
+
// `available` is false and spawn() throws — server.js gates the
|
|
14
|
+
// /api/sessions/web route on this flag so install failures degrade
|
|
15
|
+
// gracefully to wt-only mode.
|
|
16
|
+
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
let pty = null;
|
|
20
|
+
let loadError = null;
|
|
21
|
+
try {
|
|
22
|
+
pty = require('node-pty');
|
|
23
|
+
} catch (e) {
|
|
24
|
+
loadError = e;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HISTORY_BYTES = 256 * 1024;
|
|
28
|
+
|
|
29
|
+
// Map<id, { id, pty, history, sockets:Set<ws>, meta }>
|
|
30
|
+
const sessions = new Map();
|
|
31
|
+
|
|
32
|
+
function genId() {
|
|
33
|
+
return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Spawn a new PTY. `command` and `args` are passed straight to node-pty.
|
|
37
|
+
// `meta` is whatever the caller wants surfaced to the UI (title, cwd, etc).
|
|
38
|
+
// Throws if node-pty isn't available.
|
|
39
|
+
function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {} }) {
|
|
40
|
+
if (!pty) {
|
|
41
|
+
const err = new Error('node-pty is not available · ' + (loadError && loadError.message || 'unknown'));
|
|
42
|
+
err.code = 'PTY_UNAVAILABLE';
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
const id = genId();
|
|
46
|
+
const proc = pty.spawn(command, args, {
|
|
47
|
+
name: 'xterm-256color',
|
|
48
|
+
cols, rows,
|
|
49
|
+
cwd: cwd ? path.resolve(cwd) : process.cwd(),
|
|
50
|
+
env: { ...process.env, ...(env || {}) },
|
|
51
|
+
});
|
|
52
|
+
const entry = {
|
|
53
|
+
id,
|
|
54
|
+
pty: proc,
|
|
55
|
+
history: '',
|
|
56
|
+
sockets: new Set(),
|
|
57
|
+
meta: { ...meta, startedAt: Date.now(), command, args, cwd: cwd || process.cwd(), pid: proc.pid },
|
|
58
|
+
exitCode: null,
|
|
59
|
+
exitedAt: null,
|
|
60
|
+
};
|
|
61
|
+
proc.onData((data) => {
|
|
62
|
+
// Append to ring; truncate to last HISTORY_BYTES so memory stays bounded.
|
|
63
|
+
entry.history = (entry.history + data);
|
|
64
|
+
if (entry.history.length > HISTORY_BYTES) {
|
|
65
|
+
entry.history = entry.history.slice(-HISTORY_BYTES);
|
|
66
|
+
}
|
|
67
|
+
const frame = JSON.stringify({ type: 'output', data });
|
|
68
|
+
for (const ws of entry.sockets) {
|
|
69
|
+
try { ws.send(frame); } catch {}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
73
|
+
entry.exitCode = exitCode;
|
|
74
|
+
entry.exitedAt = Date.now();
|
|
75
|
+
const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
|
|
76
|
+
for (const ws of entry.sockets) {
|
|
77
|
+
try { ws.send(frame); } catch {}
|
|
78
|
+
}
|
|
79
|
+
// Keep the entry around briefly so a reconnecting client can see the
|
|
80
|
+
// exit code + final transcript, then drop it. 30s is enough for a UI
|
|
81
|
+
// re-render but won't hoard memory forever.
|
|
82
|
+
setTimeout(() => sessions.delete(id), 30_000);
|
|
83
|
+
});
|
|
84
|
+
sessions.set(id, entry);
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Wire a websocket to a session. Replays history immediately so the
|
|
89
|
+
// client sees recent context; then forwards input/resize messages from
|
|
90
|
+
// the client to the PTY and broadcast outputs back via onData above.
|
|
91
|
+
function attach(id, ws) {
|
|
92
|
+
const entry = sessions.get(id);
|
|
93
|
+
if (!entry) {
|
|
94
|
+
try { ws.close(4404, 'no such terminal'); } catch {}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
entry.sockets.add(ws);
|
|
98
|
+
if (entry.history) {
|
|
99
|
+
try { ws.send(JSON.stringify({ type: 'output', data: entry.history })); } catch {}
|
|
100
|
+
}
|
|
101
|
+
if (entry.exitedAt) {
|
|
102
|
+
try { ws.send(JSON.stringify({ type: 'exit', code: entry.exitCode })); } catch {}
|
|
103
|
+
} else {
|
|
104
|
+
try { ws.send(JSON.stringify({ type: 'attached', meta: entry.meta })); } catch {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
ws.on('message', (msg) => {
|
|
108
|
+
let event;
|
|
109
|
+
try { event = JSON.parse(msg.toString()); } catch { return; }
|
|
110
|
+
if (entry.exitedAt) return; // PTY is dead, ignore further input
|
|
111
|
+
switch (event.type) {
|
|
112
|
+
case 'input':
|
|
113
|
+
if (typeof event.data === 'string') entry.pty.write(event.data);
|
|
114
|
+
break;
|
|
115
|
+
case 'resize':
|
|
116
|
+
if (Number(event.cols) > 0 && Number(event.rows) > 0) {
|
|
117
|
+
try { entry.pty.resize(Number(event.cols), Number(event.rows)); } catch {}
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'kill':
|
|
121
|
+
kill(id);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
ws.on('close', () => {
|
|
127
|
+
entry.sockets.delete(ws);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function kill(id) {
|
|
132
|
+
const entry = sessions.get(id);
|
|
133
|
+
if (!entry || entry.exitedAt) return false;
|
|
134
|
+
try { entry.pty.kill(); } catch {}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Public summary for the frontend. Don't leak the pty / sockets objects.
|
|
139
|
+
function describe(entry) {
|
|
140
|
+
return {
|
|
141
|
+
id: entry.id,
|
|
142
|
+
meta: entry.meta,
|
|
143
|
+
attached: entry.sockets.size,
|
|
144
|
+
exitedAt: entry.exitedAt,
|
|
145
|
+
exitCode: entry.exitCode,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function list() {
|
|
150
|
+
return Array.from(sessions.values()).map(describe);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function get(id) {
|
|
154
|
+
const e = sessions.get(id);
|
|
155
|
+
return e ? describe(e) : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function killAll() {
|
|
159
|
+
for (const e of sessions.values()) {
|
|
160
|
+
try { e.pty.kill(); } catch {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
available: !!pty,
|
|
166
|
+
loadError,
|
|
167
|
+
spawn,
|
|
168
|
+
attach,
|
|
169
|
+
kill,
|
|
170
|
+
list,
|
|
171
|
+
get,
|
|
172
|
+
killAll,
|
|
173
|
+
};
|
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,26 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.3",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"ccsm": "./
|
|
8
|
+
"ccsm": "./bin/ccsm.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"server.js",
|
|
12
|
+
"bin/",
|
|
12
13
|
"lib/",
|
|
13
14
|
"public/",
|
|
15
|
+
"scripts/",
|
|
14
16
|
"README.md",
|
|
15
17
|
"CLAUDE.md"
|
|
16
18
|
],
|
|
17
19
|
"scripts": {
|
|
18
20
|
"start": "node server.js",
|
|
19
|
-
"dev": "node --watch server.js"
|
|
21
|
+
"dev": "node --watch server.js",
|
|
22
|
+
"postinstall": "node scripts/install.js",
|
|
23
|
+
"preuninstall": "node scripts/uninstall.js"
|
|
20
24
|
},
|
|
21
25
|
"dependencies": {
|
|
22
26
|
"express": "^4.21.2"
|
|
23
27
|
},
|
|
28
|
+
"optionalDependencies": {
|
|
29
|
+
"node-pty": "^1.0.0",
|
|
30
|
+
"ws": "^8.18.0"
|
|
31
|
+
},
|
|
24
32
|
"engines": {
|
|
25
33
|
"node": ">=20"
|
|
26
34
|
},
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* Reset, root typography, global accents, scrollbars, inline code/kbd. */
|
|
2
|
+
|
|
3
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
4
|
+
[hidden] { display: none !important; }
|
|
5
|
+
|
|
6
|
+
html {
|
|
7
|
+
/* Reserve the scrollbar lane so the layout never shifts horizontally
|
|
8
|
+
when content grows past one viewport. Both columns of scroll-gutter
|
|
9
|
+
stay symmetric (Firefox/Chromium both honor this). */
|
|
10
|
+
scrollbar-gutter: stable;
|
|
11
|
+
}
|
|
12
|
+
html, body {
|
|
13
|
+
background: var(--bg);
|
|
14
|
+
color: var(--ink);
|
|
15
|
+
font-family: var(--body);
|
|
16
|
+
font-size: 14px;
|
|
17
|
+
line-height: 1.5;
|
|
18
|
+
font-variant-numeric: tabular-nums;
|
|
19
|
+
min-height: 100vh;
|
|
20
|
+
-webkit-font-smoothing: antialiased;
|
|
21
|
+
-moz-osx-font-smoothing: grayscale;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
::selection { background: var(--ink); color: var(--bg-elev); }
|
|
25
|
+
|
|
26
|
+
code, .kbd {
|
|
27
|
+
font-family: var(--mono);
|
|
28
|
+
font-size: 11.5px;
|
|
29
|
+
padding: 1px 5px;
|
|
30
|
+
background: var(--bg);
|
|
31
|
+
border: 1px solid var(--border-soft);
|
|
32
|
+
border-radius: 4px;
|
|
33
|
+
color: var(--ink-mid);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Fixed scrollbar look so it doesn't fade in/out or shift width on focus.
|
|
37
|
+
WebKit (Edge/Chrome): pseudo-elements. Firefox: scrollbar-* properties. */
|
|
38
|
+
* {
|
|
39
|
+
scrollbar-width: thin;
|
|
40
|
+
scrollbar-color: var(--border-strong) transparent;
|
|
41
|
+
}
|
|
42
|
+
::-webkit-scrollbar { width: 10px; height: 10px; background: transparent; }
|
|
43
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
44
|
+
::-webkit-scrollbar-thumb {
|
|
45
|
+
background: var(--border-strong);
|
|
46
|
+
border-radius: 8px;
|
|
47
|
+
border: 2px solid var(--bg);
|
|
48
|
+
/* Forced minimum height so dragging the thumb is always practical even on
|
|
49
|
+
very long pages. */
|
|
50
|
+
min-height: 32px;
|
|
51
|
+
}
|
|
52
|
+
::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); }
|
|
53
|
+
::-webkit-scrollbar-corner { background: transparent; }
|
|
54
|
+
|
|
55
|
+
.row { display: flex; align-items: center; }
|
|
56
|
+
.gap-row { gap: var(--s-3); flex-wrap: wrap; }
|
|
57
|
+
.divider-dot { color: var(--ink-faint); padding: 0 var(--s-1); }
|
|
58
|
+
|
|
59
|
+
.muted-text { color: var(--ink-muted); font-size: 12.5px; }
|
|
60
|
+
.muted-text strong { color: var(--ink-mid); font-weight: 600; }
|
|
61
|
+
|
|
62
|
+
.post-result {
|
|
63
|
+
margin-top: var(--s-3);
|
|
64
|
+
font-family: var(--mono);
|
|
65
|
+
font-size: 11.5px;
|
|
66
|
+
color: var(--ink-muted);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.empty {
|
|
70
|
+
padding: var(--s-12) var(--s-6);
|
|
71
|
+
text-align: center;
|
|
72
|
+
font-size: 13px;
|
|
73
|
+
color: var(--ink-muted);
|
|
74
|
+
}
|
|
75
|
+
.empty code {
|
|
76
|
+
font-family: var(--mono);
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
color: var(--ink-mid);
|
|
79
|
+
background: var(--bg);
|
|
80
|
+
padding: 1px 5px;
|
|
81
|
+
border-radius: 4px;
|
|
82
|
+
}
|