mellos-mapping 0.19.0 → 0.20.2

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.
Files changed (43) hide show
  1. package/README.md +366 -56
  2. package/README.zh-CN.md +319 -47
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1737 -897
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1612 -902
  8. package/lib/domain/ops.d.ts +171 -0
  9. package/lib/domain/ops.js +384 -0
  10. package/lib/domain/types.d.ts +283 -0
  11. package/lib/domain/types.js +153 -0
  12. package/lib/render/canvas.d.ts +50 -0
  13. package/lib/render/canvas.js +210 -0
  14. package/lib/render/draw.d.ts +37 -0
  15. package/lib/render/draw.js +111 -0
  16. package/lib/render/layout.d.ts +89 -0
  17. package/lib/render/layout.js +200 -0
  18. package/lib/render/options.d.ts +39 -0
  19. package/lib/render/options.js +10 -0
  20. package/lib/render/render.d.ts +88 -0
  21. package/lib/render/render.js +128 -0
  22. package/lib/render/routing.d.ts +56 -0
  23. package/lib/render/routing.js +244 -0
  24. package/lib/render/skins.d.ts +54 -0
  25. package/lib/render/skins.js +99 -0
  26. package/lib/render/width.d.ts +24 -0
  27. package/lib/render/width.js +139 -0
  28. package/lib/render/zoom-geometry.d.ts +52 -0
  29. package/lib/render/zoom-geometry.js +56 -0
  30. package/lib/semantics/semantics.d.ts +169 -0
  31. package/lib/semantics/semantics.js +380 -0
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +67 -0
  35. package/lib/store/format.js +334 -0
  36. package/lib/store/store.d.ts +296 -0
  37. package/lib/store/store.js +734 -0
  38. package/package.json +41 -5
  39. package/scripts/codex-register.mjs +89 -20
  40. package/scripts/install-mmap-command.mjs +293 -0
  41. package/scripts/mmap.mjs +213 -0
  42. package/scripts/open-pane.mjs +115 -254
  43. package/scripts/pane-core.mjs +418 -0
@@ -0,0 +1,418 @@
1
+ /**
2
+ * What every way of opening (or closing) the map pane has in common.
3
+ *
4
+ * Two entry points sit on top of this module and they are deliberately not
5
+ * one script:
6
+ *
7
+ * scripts/open-pane.mjs — the AGENT's launcher. Takes the project directory
8
+ * as a positional argument, prints machine-readable `MMAP_PANE …` lines,
9
+ * and never closes anything: an assistant asking for the map must not be
10
+ * able to take a pane away from the user.
11
+ * scripts/mmap.mjs — the HUMAN's toggle. Takes no project directory (it
12
+ * discovers one by walking up from the cwd), prints sentences, and closes
13
+ * a pane that is already open.
14
+ *
15
+ * Folding them into one script with two personalities would mean branching on
16
+ * how it was invoked — the hidden control flow this repo refuses everywhere
17
+ * else. Sharing this module instead means the window probe, the
18
+ * already-running check and the `wt` payload have exactly one definition, and
19
+ * the two command lines stay honestly different.
20
+ *
21
+ * Everything here runs on plain node: these scripts ship in the plugin, which
22
+ * Claude Code installs by cloning the repo with no build and no npm install,
23
+ * so nothing here may import the TypeScript sources. The store's own
24
+ * vocabulary — where the map lives, what the focus and quit channels are
25
+ * called, which panes are live, what a page slug may look like — is read at
26
+ * runtime from the generated dist/store-paths.mjs (see loadPluginPaths). A
27
+ * second copy of a filename is how the focus request came to be written to a
28
+ * name no watcher ever read.
29
+ *
30
+ * Pure helpers are exported for the spec; nothing here runs on import.
31
+ */
32
+ import { spawnSync } from 'node:child_process';
33
+ import { existsSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+ import { fileURLToPath, pathToFileURL } from 'node:url';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // the flag vocabulary, shared so the two entry points cannot drift
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Watcher flags forwarded verbatim to dist/watch.mjs — the full boolean set
43
+ * parseArgs (src/watch/watch.ts) understands, minus the two the launchers own
44
+ * themselves (--file, --page). Forwarding the whole set is one rule the caller
45
+ * can hold in their head; a curated subset silently ate --no-follow.
46
+ */
47
+ export const WATCHER_BOOLEAN_FLAGS = ['--ascii', '--no-color', '--no-mouse', '--no-follow'];
48
+ /** Watcher flags that consume the next argument as their value. */
49
+ export const WATCHER_VALUE_FLAGS = ['--interval'];
50
+ /** Flags a launcher consumes itself; they never reach the watcher. */
51
+ export const PANE_FLAGS = ['--window', '--force'];
52
+
53
+ /** How the pane is placed: beside the conversation, or in its own window. */
54
+ export const PANE_MODE = { split: 'split', window: 'window' };
55
+
56
+ /**
57
+ * Read argv[i] as a WATCHER flag.
58
+ *
59
+ * @param argv - the whole argument list.
60
+ * @param i - index of the token to read.
61
+ * @returns `other` when the token belongs to the caller's own vocabulary,
62
+ * `taken` with the index of the LAST token consumed and the flags to
63
+ * forward, or `bad-value` with a message the caller wraps in its own usage.
64
+ * A Result-shaped value rather than a throw: a hand-typed command line
65
+ * getting a flag wrong is expected, not exceptional.
66
+ */
67
+ export function takeWatcherFlag(argv, i) {
68
+ const flag = argv[i];
69
+ if (WATCHER_BOOLEAN_FLAGS.includes(flag)) return { kind: 'taken', next: i, flags: [flag] };
70
+ if (!WATCHER_VALUE_FLAGS.includes(flag)) return { kind: 'other' };
71
+ const value = argv[i + 1];
72
+ if (value === undefined || !Number.isFinite(Number(value))) return { kind: 'bad-value', message: `${flag} needs a number` };
73
+ return { kind: 'taken', next: i + 1, flags: [flag, value] };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // where the plugin keeps the things these scripts run
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * The plugin root, given an entry point's `import.meta.url`.
82
+ *
83
+ * Both entry points live exactly one directory below the root — `scripts/` in
84
+ * a plugin checkout, `dist/` for the bundled `mmap` binary — so one rule
85
+ * serves both. It takes the url rather than reading its own, because this
86
+ * module is BUNDLED into dist/mmap.mjs: `import.meta.url` in here would then
87
+ * be the bundle's, and every caller would silently get the bundle's answer.
88
+ */
89
+ export function pluginRootOf(moduleUrl) {
90
+ return dirname(dirname(fileURLToPath(moduleUrl)));
91
+ }
92
+
93
+ /**
94
+ * Load the built artifacts an entry point needs: the store's path vocabulary
95
+ * and the watcher itself.
96
+ *
97
+ * @returns ok with `{ store, watchPath }`, or err with a message naming the
98
+ * missing file — an unbuilt checkout is a state to report, not a crash.
99
+ */
100
+ export async function loadPluginPaths(pluginRoot) {
101
+ const pathsModule = join(pluginRoot, 'dist', 'store-paths.mjs');
102
+ const watchPath = join(pluginRoot, 'dist', 'watch.mjs');
103
+ const missing = !existsSync(pathsModule) ? pathsModule : !existsSync(watchPath) ? watchPath : undefined;
104
+ if (missing !== undefined) {
105
+ return { ok: false, error: `the plugin is not built — run "npm run build" (missing ${missing})` };
106
+ }
107
+ return { ok: true, value: { store: await import(pathToFileURL(pathsModule).href), watchPath } };
108
+ }
109
+
110
+ /**
111
+ * Was this script RUN, or merely imported? A spec importing an entry point
112
+ * must not launch a terminal.
113
+ *
114
+ * Compared by real path: npm bin shims launch through a symlink and shells may
115
+ * pass relative paths, so the two strings rarely match as written.
116
+ * @param argv1 - process.argv[1].
117
+ * @param moduleUrl - the script's own `import.meta.url`.
118
+ */
119
+ export function launchedAsEntry(argv1, moduleUrl) {
120
+ if (argv1 === undefined) return false;
121
+ try {
122
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // the one-shot channels a running pane listens on
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Name written by 0.20.0/0.20.1 launchers for the focus channel. No watcher
134
+ * ever read it, so every steered pane left one behind in the user's project;
135
+ * writing a request now sweeps the orphan away.
136
+ */
137
+ const ORPHANED_FOCUS_FILE_NAME = 'mellos-mapping.focus';
138
+
139
+ /**
140
+ * Write a one-shot request into the store's message channel.
141
+ *
142
+ * @param path - the store's OWN path for the channel, never assembled here,
143
+ * so the writer and the reader can only ever agree.
144
+ * @param body - the request's JSON payload.
145
+ * Temp + rename because the watcher polls: a torn read would be swept as
146
+ * junk, silently losing the request.
147
+ */
148
+ function writeRequest(path, body) {
149
+ mkdirSync(dirname(path), { recursive: true });
150
+ writeFileSync(`${path}.tmp`, JSON.stringify(body));
151
+ renameSync(`${path}.tmp`, path);
152
+ }
153
+
154
+ /**
155
+ * Write the one-shot focus request a RUNNING watcher consumes (see
156
+ * takeFocusRequest in src/store/store.ts).
157
+ * @param focusFile - the store's own focus path for this map file.
158
+ */
159
+ export function writeFocusRequest(focusFile, pageSlug) {
160
+ writeRequest(focusFile, { page: pageSlug });
161
+ try {
162
+ rmSync(join(dirname(focusFile), ORPHANED_FOCUS_FILE_NAME), { force: true });
163
+ } catch {
164
+ // sweeping a dead file is a courtesy; the request itself already landed
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Write the one-shot quit request a RUNNING watcher consumes (see
170
+ * takeQuitRequest in src/store/store.ts) — the toggle's OFF half.
171
+ *
172
+ * The payload is an empty object on purpose: the store accepts any JSON
173
+ * object and reads nothing out of it, so the request says only that it was
174
+ * made. The empty object is what tells a stray file of the same name apart
175
+ * from a message.
176
+ * @param quitFile - the store's own quit path for this map file.
177
+ */
178
+ export function writeQuitRequest(quitFile) {
179
+ writeRequest(quitFile, {});
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // finding, focusing and splitting the right Windows Terminal window
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /** The `wt` payload that runs the watcher: the pane's title, cwd and command. */
187
+ export function paneCommand(cfg, watchPath, mapFile) {
188
+ const cmd = ['--title', 'mellos map', '-d', cfg.projectDir, 'node', watchPath, '--file', mapFile, ...cfg.watcherFlags];
189
+ if (cfg.pageSlug !== undefined) cmd.push('--page', cfg.pageSlug);
190
+ return cmd;
191
+ }
192
+
193
+ /** Quote one value into a PowerShell single-quoted string literal. */
194
+ export function powerShellQuote(value) {
195
+ return `'${value.replace(/'/g, "''")}'`;
196
+ }
197
+
198
+ /**
199
+ * Script that counts running watchers of `mapFile`.
200
+ *
201
+ * String.IndexOf, not `-like`: a wildcard match treats `[`, `]`, `?` and `*`
202
+ * in the path as pattern syntax, so a project under `C:\work\[wip]\app` never
203
+ * matched its own watcher and every /mmap opened another pane. Ordinal
204
+ * case-insensitive keeps the old matching behavior for Windows paths that
205
+ * differ only in case.
206
+ */
207
+ export function watcherProbeScript(mapFile) {
208
+ return (
209
+ `$ErrorActionPreference = 'SilentlyContinue'\n` +
210
+ `$needle = ${powerShellQuote(mapFile)}\n` +
211
+ `$w = @(Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object {\n` +
212
+ ` $_.CommandLine -and\n` +
213
+ ` $_.CommandLine.IndexOf('watch.mjs', [StringComparison]::OrdinalIgnoreCase) -ge 0 -and\n` +
214
+ ` $_.CommandLine.IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -ge 0\n` +
215
+ `})\n` +
216
+ `Write-Output "WATCHERS=$($w.Count)"`
217
+ );
218
+ }
219
+
220
+ // Prints IDENT=<hwnd|0> and, when identified, FOCUS=<1|0>.
221
+ const IDENTIFY_AND_FOCUS = String.raw`
222
+ $ErrorActionPreference = 'SilentlyContinue'
223
+ Add-Type @"
224
+ using System;
225
+ using System.Text;
226
+ using System.Runtime.InteropServices;
227
+ public class MmapWin {
228
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lp);
229
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lp);
230
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
231
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
232
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
233
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
234
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
235
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd);
236
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
237
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
238
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
239
+ [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo);
240
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
241
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
242
+ [DllImport("kernel32.dll")] public static extern bool FreeConsole();
243
+ [DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
244
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern bool SetConsoleTitle(string title);
245
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern uint GetConsoleTitle(StringBuilder sb, uint size);
246
+ }
247
+ "@
248
+ function Get-WtWindows {
249
+ $wins = New-Object System.Collections.ArrayList
250
+ $cb = {
251
+ param($h, $lp)
252
+ if ([MmapWin]::IsWindowVisible($h)) {
253
+ $cls = New-Object System.Text.StringBuilder 256
254
+ [void][MmapWin]::GetClassName($h, $cls, 256)
255
+ if ($cls.ToString() -eq 'CASCADIA_HOSTING_WINDOW_CLASS') {
256
+ $t = New-Object System.Text.StringBuilder 512
257
+ [void][MmapWin]::GetWindowText($h, $t, 512)
258
+ [void]$wins.Add(@{ hwnd = $h.ToInt64(); title = $t.ToString() })
259
+ }
260
+ }
261
+ return $true
262
+ }
263
+ [void][MmapWin]::EnumWindows($cb, [IntPtr]::Zero)
264
+ return ,$wins
265
+ }
266
+
267
+ $ancestors = @()
268
+ $p = $PID
269
+ for ($i = 0; $i -lt 12 -and $p; $i++) {
270
+ $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$p"
271
+ if (-not $proc) { break }
272
+ if ($i -gt 0) { $ancestors += [uint32]$proc.ProcessId }
273
+ $p = $proc.ParentProcessId
274
+ }
275
+
276
+ # The agent CLI (claude/codex/...) is some ancestor holding the console that a
277
+ # WT window renders; hidden-console ancestors just never light a window up.
278
+ $nonce = "__NONCE__"
279
+ $hwnd = [IntPtr]::Zero
280
+ foreach ($apid in $ancestors) {
281
+ [void][MmapWin]::FreeConsole()
282
+ if (-not [MmapWin]::AttachConsole($apid)) { continue }
283
+ $sb = New-Object System.Text.StringBuilder 1024
284
+ [void][MmapWin]::GetConsoleTitle($sb, 1024)
285
+ $orig = $sb.ToString()
286
+ for ($i = 0; $i -lt 6 -and $hwnd -eq [IntPtr]::Zero; $i++) {
287
+ [void][MmapWin]::SetConsoleTitle($nonce)
288
+ Start-Sleep -Milliseconds 60
289
+ foreach ($w in (Get-WtWindows)) {
290
+ if ($w.title -like "*$nonce*") { $hwnd = [IntPtr]$w.hwnd; break }
291
+ }
292
+ }
293
+ Start-Sleep -Milliseconds 100
294
+ [void][MmapWin]::SetConsoleTitle($orig)
295
+ Start-Sleep -Milliseconds 200
296
+ [void][MmapWin]::FreeConsole()
297
+ if ($hwnd -ne [IntPtr]::Zero) { break }
298
+ }
299
+
300
+ if ($hwnd -eq [IntPtr]::Zero) { Write-Output 'IDENT=0'; exit 0 }
301
+ Write-Output "IDENT=$($hwnd.ToInt64())"
302
+
303
+ if ([MmapWin]::IsIconic($hwnd)) { [void][MmapWin]::ShowWindow($hwnd, 9) }
304
+ $focused = $false
305
+ [void][MmapWin]::SetForegroundWindow($hwnd)
306
+ Start-Sleep -Milliseconds 150
307
+ if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
308
+ if (-not $focused) {
309
+ [MmapWin]::keybd_event(0x12, 0, 0, [IntPtr]::Zero)
310
+ [void][MmapWin]::SetForegroundWindow($hwnd)
311
+ [MmapWin]::keybd_event(0x12, 0, 2, [IntPtr]::Zero)
312
+ Start-Sleep -Milliseconds 150
313
+ if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
314
+ }
315
+ if (-not $focused) {
316
+ $fgpid = 0
317
+ $fgThread = [MmapWin]::GetWindowThreadProcessId([MmapWin]::GetForegroundWindow(), [ref]$fgpid)
318
+ $myThread = [MmapWin]::GetCurrentThreadId()
319
+ [void][MmapWin]::AttachThreadInput($myThread, $fgThread, $true)
320
+ [void][MmapWin]::BringWindowToTop($hwnd)
321
+ [void][MmapWin]::SetForegroundWindow($hwnd)
322
+ [void][MmapWin]::AttachThreadInput($myThread, $fgThread, $false)
323
+ Start-Sleep -Milliseconds 150
324
+ if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
325
+ }
326
+ Write-Output "FOCUS=$(if ($focused) { 1 } else { 0 })"
327
+ `;
328
+
329
+ /** How long the window probe may take before it is abandoned, in ms. */
330
+ const PROBE_TIMEOUT_MS = 30_000;
331
+ /** How long `wt` may take to open a pane before it is abandoned, in ms. */
332
+ const WT_TIMEOUT_MS = 15_000;
333
+ /** Fraction of the session window the split pane takes. */
334
+ const SPLIT_SIZE = '0.42';
335
+ /** Name of the window the pane falls back to — deterministic, never a random one. */
336
+ export const DEDICATED_WINDOW_NAME = 'mellos-mapping';
337
+
338
+ function runPowerShell(script) {
339
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
340
+ const r = spawnSync(
341
+ 'powershell.exe',
342
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
343
+ { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS, windowsHide: true },
344
+ );
345
+ return r.stdout ?? '';
346
+ }
347
+
348
+ /**
349
+ * Is a pane of `mapFile` already open?
350
+ *
351
+ * One pane per map file is enough — watch.mjs redraws on change for every
352
+ * viewer of the same file, and piling up panes on repeated /mmap is noise.
353
+ *
354
+ * The panes answer this themselves: each one refreshes a report in the
355
+ * store while it is up (the viewers channel, src/store/store.ts), so a live
356
+ * report IS a live pane. Exact, instant, cross-platform, and the same
357
+ * answer the MCP server reads when it tells an assistant whether anybody is
358
+ * looking — three surfaces, one truth.
359
+ *
360
+ * The process scan below is the fallback for exactly one case: a watcher
361
+ * that started before this version and publishes no report. It costs a
362
+ * PowerShell round trip and only ever runs on the path that is about to
363
+ * open a window anyway. It can go once no pre-0.20.2 pane can still be up.
364
+ *
365
+ * @param store - the store module (dist/store-paths.mjs), the plugin's one
366
+ * definition of where anything lives.
367
+ */
368
+ export function paneIsOpen(store, mapFile) {
369
+ if (store.readLiveViewers(mapFile, Date.now()).length > 0) return true;
370
+ if (process.platform !== 'win32') return false;
371
+ const m = runPowerShell(watcherProbeScript(mapFile)).match(/WATCHERS=(\d+)/);
372
+ return m !== null && Number(m[1]) > 0;
373
+ }
374
+
375
+ function openWt(args, what) {
376
+ const r = spawnSync('wt', args, { stdio: 'ignore', timeout: WT_TIMEOUT_MS, windowsHide: true });
377
+ return r.status === 0
378
+ ? { ok: true, value: undefined }
379
+ : { ok: false, error: `wt failed to ${what} (exit ${r.status ?? 'timeout'}) — is Windows Terminal installed?` };
380
+ }
381
+
382
+ /**
383
+ * Open the pane, wherever it can honestly be put.
384
+ *
385
+ * `--window` (cfg.mode === PANE_MODE.window) goes straight to the dedicated
386
+ * window. Otherwise the session's own Windows Terminal window is identified
387
+ * by a console-title nonce and brought to the foreground; only once it is
388
+ * provably the foreground window does `wt -w 0 sp` split it, because
389
+ * "most recently used" is the only thing `wt` can be told to target. Either
390
+ * step failing falls back to the dedicated window — deterministic, never a
391
+ * random one.
392
+ *
393
+ * @returns ok with how it was placed (the caller words the news for its own
394
+ * audience), or err with a message when `wt` itself refused.
395
+ */
396
+ export function placePane(cfg, watchPath, mapFile) {
397
+ const dedicated = (reason) => {
398
+ const opened = openWt(['-w', DEDICATED_WINDOW_NAME, 'nt', ...paneCommand(cfg, watchPath, mapFile)], 'open the dedicated window');
399
+ return opened.ok ? { ok: true, value: { mode: PANE_MODE.window, reason } } : opened;
400
+ };
401
+ if (cfg.mode === PANE_MODE.window) return dedicated('requested');
402
+
403
+ const nonce = `MMAP-NONCE-${process.pid}`;
404
+ const out = runPowerShell(IDENTIFY_AND_FOCUS.replaceAll('__NONCE__', nonce));
405
+ const ident = out.match(/IDENT=(\d+)/)?.[1] ?? '0';
406
+ if (ident === '0') return dedicated('session-window-not-identified');
407
+ if (!/FOCUS=1/.test(out)) return dedicated('session-window-focus-denied');
408
+
409
+ // The identified window is foreground right now, so "most recently used" is
410
+ // deterministically it. Known race, accepted: a user who focuses a DIFFERENT
411
+ // terminal window in the ~1s before wt reads its MRU state can still get the
412
+ // split there — a window they are at least actively in.
413
+ const split = openWt(
414
+ ['-w', '0', 'sp', '-V', '--size', SPLIT_SIZE, ...paneCommand(cfg, watchPath, mapFile)],
415
+ 'split the session window',
416
+ );
417
+ return split.ok ? { ok: true, value: { mode: PANE_MODE.split, hwnd: ident } } : split;
418
+ }