@bakapiano/ccsm 0.5.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/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/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
- public static bool Activate(IntPtr h) {
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: { ...process.env, CCSM_FOCUS_MODE: mode, CCSM_FOCUS_ARG: String(arg) },
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/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
- await runGit(['clone', '--progress', repo.url, repo.name], workspacePath, {
195
- onProgress,
196
- onLine,
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.5.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",