@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 +134 -0
- package/README.md +58 -0
- package/lib/config.js +98 -0
- package/lib/focus.js +235 -0
- package/lib/launcher.js +219 -0
- package/lib/sessions.js +119 -0
- package/lib/snapshot.js +141 -0
- package/lib/workspace.js +229 -0
- package/package.json +49 -0
- package/public/app.js +551 -0
- package/public/index.html +155 -0
- package/public/styles.css +136 -0
- package/server.js +339 -0
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
|
+
};
|