mellos-mapping 0.20.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.
- package/README.md +360 -63
- package/README.zh-CN.md +314 -54
- package/dist/hook-session-start.mjs +239 -0
- package/dist/mmap.mjs +338 -0
- package/dist/server.mjs +1614 -809
- package/dist/store-paths.mjs +107 -0
- package/dist/watch.mjs +1391 -760
- package/lib/domain/ops.d.ts +71 -12
- package/lib/domain/ops.js +145 -14
- package/lib/domain/types.d.ts +47 -6
- package/lib/domain/types.js +34 -3
- package/lib/render/canvas.d.ts +50 -0
- package/lib/render/canvas.js +210 -0
- package/lib/render/draw.d.ts +37 -0
- package/lib/render/draw.js +111 -0
- package/lib/render/layout.d.ts +89 -0
- package/lib/render/layout.js +200 -0
- package/lib/render/options.d.ts +39 -0
- package/lib/render/options.js +10 -0
- package/lib/render/render.d.ts +32 -46
- package/lib/render/render.js +58 -789
- package/lib/render/routing.d.ts +56 -0
- package/lib/render/routing.js +244 -0
- package/lib/render/skins.d.ts +54 -0
- package/lib/render/skins.js +99 -0
- package/lib/render/width.d.ts +24 -0
- package/lib/render/width.js +139 -0
- package/lib/render/zoom-geometry.d.ts +52 -0
- package/lib/render/zoom-geometry.js +56 -0
- package/lib/semantics/semantics.d.ts +53 -4
- package/lib/semantics/semantics.js +130 -6
- package/lib/semantics/vocabulary.d.ts +79 -0
- package/lib/semantics/vocabulary.js +112 -0
- package/lib/store/format.d.ts +17 -0
- package/lib/store/format.js +185 -66
- package/lib/store/store.d.ts +220 -20
- package/lib/store/store.js +491 -38
- package/package.json +12 -4
- package/scripts/codex-register.mjs +89 -20
- package/scripts/install-mmap-command.mjs +293 -0
- package/scripts/mmap.mjs +213 -0
- package/scripts/open-pane.mjs +115 -254
- 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
|
+
}
|