@bakapiano/ccsm 0.1.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 ADDED
@@ -0,0 +1,134 @@
1
+ # ccsm — Claude Code Session Manager
2
+
3
+ A small Node/Express + vanilla-JS web tool that gives a single pane over all live Claude Code sessions on this machine, snapshots them, restores them through Windows Terminal, and launches new sessions inside isolated workspaces.
4
+
5
+ ## Why this exists
6
+
7
+ When you're running 8–10 concurrent `claude` sessions across ad-hoc clones (`D:\proj`, `D:\proj2`, `…`, plus GUID worktree dirs), it's easy to lose track of which terminal is which session. ccsm gives an at-a-glance list and a snapshot/restore safety net.
8
+
9
+ ## Run
10
+
11
+ ```powershell
12
+ # from a checkout
13
+ node server.js
14
+
15
+ # zero-install
16
+ npx github:bakapiano/cssm
17
+ ```
18
+ Then open http://localhost:7777.
19
+
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
+
22
+ - `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.
23
+ - `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>`.
24
+ - `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.
25
+ - `autoFocusOnLaunch` (default true) — after every launch (new session, finder, resume, restore) the server takes an HWND snapshot of terminal windows, polls for a new HWND, and `SetForegroundWindow`s it. See gotcha below — wt is multi-window single-process, so we diff HWNDs not PIDs.
26
+
27
+ ## Layout
28
+
29
+ ```
30
+ D:\ccsm\
31
+ ├── server.js # Express app + 60s auto-snapshot loop
32
+ ├── lib\
33
+ │ ├── sessions.js # reads ~/.claude/sessions/*.json + cross-checks live PIDs (tasklist)
34
+ │ │ # + pulls last ai-title from ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
35
+ │ ├── snapshot.js # save/load/rotate/restore — restore = launch one wt window per session
36
+ │ ├── workspace.js # workspace = subfolder under workDir holding repo clones;
37
+ │ │ # "in use" = any live session's cwd is at/under the workspace path
38
+ │ ├── launcher.js # dispatches across terminal kinds (wt/powershell/pwsh/cmd);
39
+ │ │ # path.resolve()s cwd; throws if cwd doesn't exist
40
+ │ ├── focus.js # PowerShell + Win32 — listWindowsOf, focusByHwnd, focusByPid,
41
+ │ │ # focusNewlyOpenedHwnd (HWND-diff for auto-focus on launch)
42
+ │ └── config.js # loadConfig/saveConfig with defaults
43
+ ├── public\
44
+ │ ├── index.html, app.js, styles.css # vanilla, auto-refresh every 5s
45
+ └── package.json # bin entry → `ccsm` (for npx github:bakapiano/cssm)
46
+
47
+ ~/.ccsm/ # or $CCSM_HOME
48
+ ├── config.json # source of truth
49
+ ├── snapshot.json # latest snapshot, rewritten every 60s
50
+ └── snapshots/ # rotating history (default keep=30)
51
+ ```
52
+
53
+ 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.
54
+
55
+ ## Locked-in design decisions
56
+
57
+ **Workspace = folder holding multiple repo clones.** Each `ws-N` under `workDir` contains a subdirectory per cloned repo. Claude launches at the workspace root so all selected repos are sibling folders.
58
+
59
+ ```
60
+ D:\ccsm-workspaces\
61
+ ├── ws-1\
62
+ │ ├── repo-a\
63
+ │ └── repo-b\
64
+ ├── ws-2\
65
+ │ └── repo-a\
66
+ ```
67
+
68
+ The alternative ("one repo per workspace") was explicitly rejected — don't refactor toward it without re-confirming.
69
+
70
+ **wt: one window per session, not stacked tabs.** Both `/api/snapshot/restore` and `/api/sessions/new` open a fresh `wt` window. The alternative ("`-w 0 nt` stacking in one window") was explicitly rejected — tabs become hard to track when restoring 8+ sessions.
71
+
72
+ **"In use" detection.** A workspace is in use iff any live Claude session's cwd is at-or-inside the workspace path (case-insensitive Windows compare via `path.resolve().toLowerCase()`). New-session always tries to pick a free workspace before creating `ws-N+1`.
73
+
74
+ **Workspace naming.** Auto-allocated names are `ws-1`, `ws-2`, … (lowest free integer). Hand-named folders the user drops under `workDir` are still picked up — workspace name = literal folder name.
75
+
76
+ ## API surface
77
+
78
+ | Method | Path | Purpose |
79
+ |---|---|---|
80
+ | GET | `/api/sessions` | live sessions sorted by `updatedAt` desc |
81
+ | GET / PUT | `/api/config` | read / replace config (merged against defaults) |
82
+ | GET / POST | `/api/snapshot` | latest snapshot / force-save now |
83
+ | GET | `/api/snapshot/history` | rotated history filenames |
84
+ | POST | `/api/snapshot/restore` | launch one wt window per session in latest snapshot (body `{file}` for historic) |
85
+ | GET | `/api/workspaces` | workspaces under workDir with repo clone status + in-use flag |
86
+ | POST | `/api/sessions/new` | body `{repos, workspace?, launch?}` — picks/creates ws, clones missing repos, launches wt |
87
+ | POST | `/api/sessions/finder` | opens a wt with `claude` in `D:\ccsm` and the `finderPrompt` as opening message |
88
+ | POST | `/api/sessions/:id/resume` | body `{cwd}` — launches `wt -d <cwd> claude --resume <id>` |
89
+ | POST | `/api/sessions/:id/focus` | walks up the claude PID parent chain to find the wt window, raises it via SetForegroundWindow |
90
+ | GET | `/api/terminals` | enumerate built-in terminal kinds + their process names |
91
+ | GET | `/api/health` | sanity ping |
92
+
93
+ `/api/sessions/new` streams **NDJSON** (one JSON object per line, `Content-Type: application/x-ndjson`). Event types: `workspace`, `clone-start`, `clone-progress` (phase/percent/current/total/detail), `clone-line` (raw git stderr line when not a progress line), `clone-end`, `launched`, `done`. The frontend reads it with `fetch().body.getReader()` + `TextDecoder` and updates per-repo progress bars live.
94
+
95
+ ## Non-obvious gotchas
96
+
97
+ **wt.exe `-d` flag, verified variants** (marker-file probes confirming `%CD%` inside the new tab):
98
+
99
+ | variant | result |
100
+ |---|---|
101
+ | `wt -d D:\ccsm <cmd>` | ✓ |
102
+ | `wt -d D:/ccsm <cmd>` | ✓ (forward slashes fine) |
103
+ | `wt --startingDirectory D:\ccsm <cmd>` | ✓ |
104
+ | `wt -d D:\ccsm\ <cmd>` (trailing sep) | ✓ |
105
+ | spawn `{ cwd: ... }` with no `-d` | ✗ wt ignores parent cwd |
106
+ | `wt -d "D:\ccsm" <cmd>` (literal quotes) | ✗ wt doesn't open |
107
+ | `wt new-tab -d D:\ccsm <cmd>` | ✗ wt doesn't open |
108
+
109
+ ccsm uses variant 1 and always `path.resolve()`s the cwd first — defends against a malformed `D:ccsm` (no separator) being interpreted as "current dir on drive D + ccsm", which would resolve to e.g. `D:\ccsm\ccsm`.
110
+
111
+ **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.
112
+
113
+ **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:
114
+ 1. C# `out _` discard breaks PowerShell 5.1's bundled C# compiler — use a named `uint dummy`.
115
+ 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.
116
+ 3. `ConvertTo-Json -AsArray` is PS 7+; on PS 5.1 build the JSON array manually as `'[' + ($items -join ',') + ']'` to avoid the single-element flattening.
117
+
118
+ **HWND-diff for auto-focus, not PID-diff.** Modern Windows Terminal is multi-window single-process: one `WindowsTerminal.exe` PID owns 8+ top-level HWNDs. So `tasklist`-style PID-set-diff after launch returns empty even though a new window actually opened. We list visible top-level windows (filtered by owning-process name) via `EnumWindows`, snapshot the HWND set before launch, poll after, focus the new HWND. Works uniformly for wt and the per-process terminals (`powershell.exe` etc) where each launch is also a new process.
119
+
120
+ **wt `-w new`.** wt's `windowingBehavior` setting in some user profiles folds new `wt …` invocations into the existing window as a tab, breaking "one window per session". Force-prepending `-w new` makes wt always create a new window (which is one of those many HWNDs above). Without `-w new`, both the auto-focus path and the design-decision-of-one-window-per-session silently break.
121
+
122
+ **Auto-snapshot loop.** `setInterval` in `server.js` calls `saveSnapshot` every `snapshotIntervalMs`. The history dir grows until `snapshotHistoryKeep` is exceeded, then oldest are pruned.
123
+
124
+ **Session listing.** `~/.claude/sessions/<pid>.json` is the source of truth. We cross-check the `pid` field against `tasklist /FI "IMAGENAME eq claude.exe"` so stale entries from crashed/exited claudes don't appear. `ai-title` is read by tailing the last 1 MB of the matching `.jsonl` and finding the last `"type":"ai-title"` line.
125
+
126
+ **`projectSlugForCwd`.** The path-to-slug mapping is `cwd.replace(/[:\\]/g, '-')`, e.g. `D:\ccsm` → `D--ccsm`, `C:\Users\foo` → `C--Users-foo`, `D:\` → `D--`.
127
+
128
+ ## Extending
129
+
130
+ When adding features, the natural extension points:
131
+ - New REST routes: `server.js` (keep them under `/api/*`, use `asyncH` wrapper).
132
+ - Frontend section: add a `<section class="panel">` in `public/index.html` and a render function in `public/app.js`.
133
+ - Workspace lifecycle (delete, rename): `lib/workspace.js`.
134
+ - Different launch modes (e.g., stacked tabs): `lib/launcher.js` — but check first whether the "one window per session" decision still holds.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # ccsm — Claude Code Session Manager
2
+
3
+ A small web UI + Node server (Windows-only) that:
4
+
5
+ - Lists every live Claude Code session on the machine, sorted by last active time, with title / cwd / age / PID and a one-click **focus** button that raises the already-open wt window (via `EnumWindows` + `SetForegroundWindow` with the Alt-key trick) and a **resume new** button that opens a fresh `wt -d <cwd> claude --resume <id>`.
6
+ - Snapshots the full session set every minute (`data/snapshot.json` + rotated history under `data/snapshots/`). One click **restores** the snapshot — one new wt window per session, `cd` + `claude --resume`.
7
+ - **New session** picks an unused workspace under your work directory, clones the repos you selected (streaming live `git clone --progress` to a per-repo progress bar in the UI), then opens a fresh terminal window running `claude` (or whichever command you set).
8
+ - **Ask Claude to find a session** opens a Claude Code session pre-pointed at this repo so you can grep through past conversations.
9
+
10
+ ## Quick start
11
+
12
+ ```powershell
13
+ # one-liner — no clone needed
14
+ npx github:bakapiano/cssm
15
+ # open http://localhost:7777
16
+ ```
17
+
18
+ Or from a checkout:
19
+
20
+ ```powershell
21
+ git clone https://github.com/bakapiano/cssm.git
22
+ cd cssm
23
+ npm install
24
+ node server.js
25
+ ```
26
+
27
+ ccsm stores its config + snapshots under `~/.ccsm/` so it survives across upgrades, npx cache wipes, and multiple checkouts. Override with `CCSM_HOME=<path>` if you want it elsewhere.
28
+
29
+ ## Layout
30
+
31
+ ```
32
+ ccsm\
33
+ ├── server.js # Express app + 60s auto-snapshot loop
34
+ ├── lib\
35
+ │ ├── sessions.js # ~/.claude/sessions/*.json + live PID cross-check via tasklist
36
+ │ ├── snapshot.js # save / load / rotate / restore
37
+ │ ├── workspace.js # workspace = subfolder under workDir; clone repos with progress
38
+ │ ├── launcher.js # dispatch across wt / powershell / pwsh / cmd
39
+ │ ├── focus.js # PowerShell + Win32 — listWindowsOf, focusByHwnd, focusByPid
40
+ │ └── config.js # load/save data/config.json
41
+ ├── public\ # vanilla HTML/JS frontend, auto-refresh every 5s
42
+ ~/.ccsm/ # (or $CCSM_HOME)
43
+ ├── config.json # source of truth
44
+ ├── snapshot.json # latest auto-snapshot
45
+ └── snapshots/ # rotating history
46
+ ```
47
+
48
+ ## Defaults
49
+
50
+ - Port: `7777`
51
+ - Work dir: `~/ccsm-workspaces` (configurable; each workspace holds one or more repo clones)
52
+ - Terminal: `wt` (Windows Terminal). Also: `powershell` | `pwsh` | `cmd`.
53
+ - claude command: `claude` — any string. When terminal is `wt`, the command is wrapped in `pwsh -EncodedCommand …` (configurable as `commandShell`) so PowerShell aliases / functions / profile-defined names like `ccp` resolve correctly.
54
+ - Auto-focus on launch: on (HWND diff across the terminal process — works for modern wt's multi-window-single-process layout).
55
+ - Snapshot interval: 60s; last 30 kept.
56
+ - Default repos: none — add your own through the Config panel (URL is whatever `git clone` accepts).
57
+
58
+ See [CLAUDE.md](CLAUDE.md) for design decisions and the non-obvious gotchas baked into the launcher / focus / snapshot code.
package/lib/config.js ADDED
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const fsSync = require('node:fs');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+
8
+ // Data dir lives under ~/.ccsm by default so config survives across upgrades
9
+ // (incl. running from a new npx checkout). Override with CCSM_HOME if you
10
+ // want a different location.
11
+ const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
12
+ const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
13
+
14
+ const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
15
+
16
+ const DEFAULTS = {
17
+ port: 7777,
18
+ workDir: path.join(os.homedir(), 'ccsm-workspaces'),
19
+ snapshotIntervalMs: 60 * 1000,
20
+ snapshotHistoryKeep: 30,
21
+ claudeCommand: 'claude',
22
+ terminal: 'wt',
23
+ commandShell: 'pwsh',
24
+ autoFocusOnLaunch: true,
25
+ // Add the repos you most often need on hand. The "new session" button
26
+ // clones any selected entries into the workspace before launching claude.
27
+ // Example shape:
28
+ // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
29
+ repos: [],
30
+ finderPrompt:
31
+ `Help me find an old Claude Code session on this machine. ccsm's data dir is ${DATA_DIR} (latest snapshot at snapshot.json, history under snapshots/). Live sessions are at ~/.claude/sessions/*.json and conversation transcripts under ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl. Ask me what I'm looking for and grep accordingly.`,
32
+ };
33
+
34
+ function ensureDataDir() {
35
+ if (!fsSync.existsSync(DATA_DIR)) {
36
+ fsSync.mkdirSync(DATA_DIR, { recursive: true });
37
+ }
38
+ }
39
+
40
+ // If we find a legacy <repo>/data dir from before the home-dir move AND
41
+ // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
42
+ // empty so existing users with both dirs aren't clobbered.
43
+ function migrateLegacyDataIfNeeded() {
44
+ if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
45
+ if (LEGACY_DATA_DIR === DATA_DIR) return;
46
+ ensureDataDir();
47
+ const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
48
+ if (!dataEmpty) return;
49
+ try {
50
+ fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
51
+ console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
52
+ console.log(`[ccsm] safe to remove the legacy dir when you're sure: rmdir /s "${LEGACY_DATA_DIR}"`);
53
+ } catch (e) {
54
+ console.error('[ccsm] legacy migration failed:', e.message);
55
+ }
56
+ }
57
+
58
+ migrateLegacyDataIfNeeded();
59
+
60
+ function mergeWithDefaults(partial) {
61
+ const out = { ...DEFAULTS, ...partial };
62
+ if (!Array.isArray(out.repos)) {
63
+ out.repos = DEFAULTS.repos;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ async function loadConfig() {
69
+ ensureDataDir();
70
+ try {
71
+ const raw = await fs.readFile(CONFIG_PATH, 'utf8');
72
+ return mergeWithDefaults(JSON.parse(raw));
73
+ } catch (e) {
74
+ if (e.code === 'ENOENT') {
75
+ const cfg = { ...DEFAULTS };
76
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(cfg, null, 2));
77
+ return cfg;
78
+ }
79
+ throw e;
80
+ }
81
+ }
82
+
83
+ async function saveConfig(partial) {
84
+ ensureDataDir();
85
+ const current = await loadConfig();
86
+ const next = mergeWithDefaults({ ...current, ...partial });
87
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(next, null, 2));
88
+ return next;
89
+ }
90
+
91
+ module.exports = {
92
+ loadConfig,
93
+ saveConfig,
94
+ DATA_DIR,
95
+ CONFIG_PATH,
96
+ LEGACY_DATA_DIR,
97
+ DEFAULTS,
98
+ };
package/lib/focus.js ADDED
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+
3
+ // Focus helpers — find or raise a wt (or other terminal) window via Win32
4
+ // APIs called through PowerShell + Add-Type.
5
+ //
6
+ // Two distinct use cases:
7
+ // 1. focusByPid(pid) — for the "focus" button in the UI: given
8
+ // a live claude.exe PID, walk parents to
9
+ // find the wt window hosting it.
10
+ // 2. snapshotWindowsOf(name) +
11
+ // focusNewlyOpenedHwnd(...) — for auto-focus after launch: snapshot the
12
+ // set of visible top-level windows owned by
13
+ // processes named e.g. "WindowsTerminal.exe"
14
+ // BEFORE launch, then poll for new HWNDs
15
+ // and focus the diff. HWND-based because
16
+ // modern wt is multi-window single-process —
17
+ // PID-based diff would always return empty.
18
+
19
+ const { spawn } = require('node:child_process');
20
+
21
+ function sleep(ms) {
22
+ return new Promise((r) => setTimeout(r, ms));
23
+ }
24
+
25
+ // One PowerShell helper handles both: list-windows-of and focus-hwnd modes.
26
+ // Mode is passed via env var so we don't fight quoting.
27
+ const FOCUS_HELPER_PS = String.raw`
28
+ $ErrorActionPreference = 'Stop'
29
+
30
+ Add-Type @'
31
+ using System;
32
+ using System.Collections.Generic;
33
+ using System.Runtime.InteropServices;
34
+ using System.Text;
35
+ public class CcsmWin {
36
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
37
+ [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr h, bool t);
38
+ [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr h, int n);
39
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
40
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
41
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool x);
42
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
43
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
44
+ [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
45
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
46
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr p);
47
+ [DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int max);
48
+ [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr h);
49
+ public delegate bool EnumWindowsProc(IntPtr h, IntPtr p);
50
+
51
+ public static List<object> EnumVisibleTopLevel() {
52
+ var results = new List<object>();
53
+ EnumWindows((h, l) => {
54
+ if (!IsWindowVisible(h)) return true;
55
+ int len = GetWindowTextLength(h);
56
+ if (len == 0) return true;
57
+ uint pid = 0;
58
+ GetWindowThreadProcessId(h, out pid);
59
+ var sb = new StringBuilder(len + 1);
60
+ GetWindowText(h, sb, sb.Capacity);
61
+ results.Add(new { hwnd = h.ToInt64(), pid = pid, title = sb.ToString() });
62
+ return true;
63
+ }, IntPtr.Zero);
64
+ return results;
65
+ }
66
+
67
+ public static bool Activate(IntPtr h) {
68
+ if (IsIconic(h)) ShowWindowAsync(h, 9);
69
+ keybd_event(0x12, 0, 0, UIntPtr.Zero);
70
+ keybd_event(0x12, 0, 0x0002, UIntPtr.Zero);
71
+ uint ownerPid = 0;
72
+ uint t = GetWindowThreadProcessId(h, out ownerPid);
73
+ uint c = GetCurrentThreadId();
74
+ bool attached = false;
75
+ if (t != c) attached = AttachThreadInput(c, t, true);
76
+ BringWindowToTop(h);
77
+ bool ok = SetForegroundWindow(h);
78
+ SwitchToThisWindow(h, true);
79
+ if (attached) AttachThreadInput(c, t, false);
80
+ return ok;
81
+ }
82
+ }
83
+ '@ | Out-Null
84
+
85
+ $mode = $env:CCSM_FOCUS_MODE
86
+ $arg = $env:CCSM_FOCUS_ARG
87
+
88
+ if ($mode -eq 'list') {
89
+ $procName = $arg -replace '\.exe$',''
90
+ $pidSet = @{}
91
+ foreach ($p in (Get-Process -Name $procName -ErrorAction SilentlyContinue)) {
92
+ $pidSet[[uint32]$p.Id] = $true
93
+ }
94
+ $all = [CcsmWin]::EnumVisibleTopLevel()
95
+ $items = @()
96
+ foreach ($w in $all) {
97
+ if ($pidSet.ContainsKey([uint32]$w.pid)) {
98
+ $items += (ConvertTo-Json @{
99
+ hwnd = [int64]$w.hwnd
100
+ pid = [int64]$w.pid
101
+ title = [string]$w.title
102
+ } -Compress)
103
+ }
104
+ }
105
+ Write-Output ('[' + ($items -join ',') + ']')
106
+ exit 0
107
+ }
108
+
109
+ if ($mode -eq 'focus-hwnd') {
110
+ $hwndInt = [int64]$arg
111
+ $hwnd = [IntPtr]::new($hwndInt)
112
+ $ok = [CcsmWin]::Activate($hwnd)
113
+ Write-Output (ConvertTo-Json @{ ok = $true; activated = $ok; hwnd = $hwndInt } -Compress)
114
+ exit 0
115
+ }
116
+
117
+ if ($mode -eq 'focus-pid') {
118
+ $current = [int]$arg
119
+ $hwnd = [IntPtr]::Zero
120
+ $found = $null
121
+ $chain = @()
122
+ for ($i = 0; $i -lt 12; $i++) {
123
+ $p = $null
124
+ try { $p = Get-Process -Id $current -ErrorAction Stop } catch { break }
125
+ $chain += @{ pid = $p.Id; name = $p.ProcessName; hwnd = $p.MainWindowHandle.ToInt64() }
126
+ if ($p.MainWindowHandle -ne [IntPtr]::Zero) { $hwnd = $p.MainWindowHandle; $found = $p; break }
127
+ $parent = Get-CimInstance Win32_Process -Filter "ProcessId=$current" -ErrorAction SilentlyContinue | Select-Object -First 1
128
+ if (-not $parent -or -not $parent.ParentProcessId) { break }
129
+ $current = [int]$parent.ParentProcessId
130
+ }
131
+ if ($hwnd -eq [IntPtr]::Zero) {
132
+ Write-Output (ConvertTo-Json @{ ok = $false; error = "no window handle found for pid $arg"; chain = $chain } -Compress -Depth 5)
133
+ exit 1
134
+ }
135
+ $activated = [CcsmWin]::Activate($hwnd)
136
+ Write-Output (ConvertTo-Json @{
137
+ ok = $true; activated = $activated
138
+ hwnd = $hwnd.ToInt64()
139
+ windowPid = $found.Id
140
+ windowProcess = $found.ProcessName
141
+ windowTitle = $found.MainWindowTitle
142
+ chain = $chain
143
+ } -Compress -Depth 5)
144
+ exit 0
145
+ }
146
+
147
+ Write-Output (ConvertTo-Json @{ ok = $false; error = "unknown CCSM_FOCUS_MODE: $mode" } -Compress)
148
+ exit 1
149
+ `;
150
+
151
+ function runPsHelper(mode, arg) {
152
+ return new Promise((resolve, reject) => {
153
+ const encoded = Buffer.from(FOCUS_HELPER_PS, 'utf16le').toString('base64');
154
+ const child = spawn(
155
+ 'powershell.exe',
156
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
157
+ {
158
+ windowsHide: true,
159
+ env: { ...process.env, CCSM_FOCUS_MODE: mode, CCSM_FOCUS_ARG: String(arg) },
160
+ }
161
+ );
162
+ let out = '';
163
+ let err = '';
164
+ child.stdout.on('data', (d) => (out += d.toString()));
165
+ child.stderr.on('data', (d) => (err += d.toString()));
166
+ child.on('error', reject);
167
+ child.on('close', (code) => {
168
+ const last = out.trim().split(/\r?\n/).pop();
169
+ try {
170
+ const parsed = JSON.parse(last);
171
+ if (Array.isArray(parsed)) {
172
+ resolve({ exitCode: code, value: parsed, stderr: err.trim() || undefined });
173
+ } else {
174
+ resolve({ exitCode: code, ...parsed, stderr: err.trim() || undefined });
175
+ }
176
+ } catch (e) {
177
+ reject(
178
+ new Error(
179
+ `focus helper (mode=${mode}) exit ${code}: ${err || out || '(no output)'}`
180
+ )
181
+ );
182
+ }
183
+ });
184
+ });
185
+ }
186
+
187
+ // ---- public API ----
188
+
189
+ async function focusByPid(pid) {
190
+ const n = Number(pid);
191
+ if (!Number.isInteger(n) || n <= 0) throw new Error(`focusByPid: invalid pid ${pid}`);
192
+ return await runPsHelper('focus-pid', n);
193
+ }
194
+
195
+ async function focusByHwnd(hwnd) {
196
+ return await runPsHelper('focus-hwnd', hwnd);
197
+ }
198
+
199
+ async function listWindowsOf(processName) {
200
+ const r = await runPsHelper('list', processName);
201
+ return Array.isArray(r.value) ? r.value : [];
202
+ }
203
+
204
+ async function snapshotWindowsOf(processName) {
205
+ const list = await listWindowsOf(processName);
206
+ return new Set(list.map((w) => Number(w.hwnd)));
207
+ }
208
+
209
+ async function focusNewlyOpenedHwnd(beforeHwnds, processName, opts = {}) {
210
+ const { timeoutMs = 8000, intervalMs = 300 } = opts;
211
+ const deadline = Date.now() + timeoutMs;
212
+ await sleep(intervalMs);
213
+ while (Date.now() < deadline) {
214
+ const after = await listWindowsOf(processName);
215
+ const fresh = after.filter((w) => !beforeHwnds.has(Number(w.hwnd)));
216
+ if (fresh.length > 0) {
217
+ const target = fresh[fresh.length - 1];
218
+ const r = await focusByHwnd(target.hwnd);
219
+ return { ...r, hwnd: target.hwnd, title: target.title, candidates: fresh };
220
+ }
221
+ await sleep(intervalMs);
222
+ }
223
+ return {
224
+ ok: false,
225
+ error: `no new ${processName} window appeared within ${timeoutMs}ms`,
226
+ };
227
+ }
228
+
229
+ module.exports = {
230
+ focusByPid,
231
+ focusByHwnd,
232
+ listWindowsOf,
233
+ snapshotWindowsOf,
234
+ focusNewlyOpenedHwnd,
235
+ };