mellos-mapping 0.20.3 → 0.23.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.
Files changed (75) hide show
  1. package/README.md +130 -69
  2. package/README.zh-CN.md +126 -65
  3. package/dist/hook-session-start.mjs +28 -20
  4. package/dist/mmap.mjs +303 -171
  5. package/dist/preview.mjs +1457 -0
  6. package/dist/server.mjs +1984 -741
  7. package/dist/store-paths.mjs +69 -22
  8. package/dist/terminal-worker.mjs +3331 -0
  9. package/dist/watch.mjs +957 -571
  10. package/dist/web/TERMINAL-LICENSES.txt +70 -0
  11. package/dist/web/app.css +967 -0
  12. package/dist/web/app.js +1356 -0
  13. package/dist/web/index.html +9 -0
  14. package/dist/web/terminal.css +9 -0
  15. package/dist/web/terminal.html +7 -0
  16. package/dist/web/terminal.js +9293 -0
  17. package/dist/web/xterm.css +285 -0
  18. package/dist/web.mjs +5646 -0
  19. package/docs/codex.md +189 -0
  20. package/docs/map-api.md +148 -0
  21. package/lib/domain/context.d.ts +11 -0
  22. package/lib/domain/context.js +27 -0
  23. package/lib/domain/text.d.ts +9 -0
  24. package/lib/domain/text.js +54 -0
  25. package/lib/domain/types.d.ts +3 -0
  26. package/lib/preview/index.d.ts +3 -0
  27. package/lib/preview/index.js +3 -0
  28. package/lib/preview/markdown.d.ts +8 -0
  29. package/lib/preview/markdown.js +54 -0
  30. package/lib/preview/presentation.d.ts +6 -0
  31. package/lib/preview/presentation.js +14 -0
  32. package/lib/preview/publisher.d.ts +23 -0
  33. package/lib/preview/publisher.js +143 -0
  34. package/lib/preview/svg.d.ts +3 -0
  35. package/lib/preview/svg.js +74 -0
  36. package/lib/preview/text.d.ts +4 -0
  37. package/lib/preview/text.js +13 -0
  38. package/lib/render/canvas.d.ts +1 -1
  39. package/lib/render/canvas.js +4 -2
  40. package/lib/render/draw.js +9 -6
  41. package/lib/render/render.d.ts +6 -0
  42. package/lib/render/render.js +50 -18
  43. package/lib/render/width.js +3 -1
  44. package/lib/store/atomic.d.ts +18 -0
  45. package/lib/store/atomic.js +86 -0
  46. package/lib/store/channels.d.ts +40 -0
  47. package/lib/store/channels.js +135 -0
  48. package/lib/store/format.js +21 -4
  49. package/lib/store/json-text.d.ts +9 -0
  50. package/lib/store/json-text.js +16 -0
  51. package/lib/store/maps.d.ts +12 -0
  52. package/lib/store/maps.js +42 -0
  53. package/lib/store/migration.d.ts +12 -0
  54. package/lib/store/migration.js +46 -0
  55. package/lib/store/pages.d.ts +46 -0
  56. package/lib/store/pages.js +89 -0
  57. package/lib/store/policy.d.ts +74 -0
  58. package/lib/store/policy.js +144 -0
  59. package/lib/store/project.d.ts +2 -0
  60. package/lib/store/project.js +29 -0
  61. package/lib/store/store.d.ts +15 -257
  62. package/lib/store/store.js +16 -695
  63. package/lib/store/transaction.d.ts +12 -0
  64. package/lib/store/transaction.js +91 -0
  65. package/lib/store/viewers.d.ts +81 -0
  66. package/lib/store/viewers.js +186 -0
  67. package/package.json +27 -5
  68. package/scripts/codex-cli.mjs +42 -0
  69. package/scripts/codex-register.mjs +27 -94
  70. package/scripts/mmap.mjs +27 -17
  71. package/scripts/open-pane.mjs +37 -18
  72. package/scripts/pane-core.mjs +57 -220
  73. package/scripts/terminal-session.mjs +137 -0
  74. package/scripts/tmux-session.mjs +90 -0
  75. package/scripts/watcher-command.mjs +16 -0
package/scripts/mmap.mjs CHANGED
@@ -3,9 +3,9 @@
3
3
  * `mmap` — the human's entrance to the map pane. A TOGGLE.
4
4
  *
5
5
  * mmap open the pane for this project, or close the one
6
- * that is already open
6
+ * that belongs to this console
7
7
  * mmap <slug> open the pane ON that page, or retarget the pane
8
- * that is already open. Never closes anything:
8
+ * that belongs to this console. Never closes anything:
9
9
  * naming a page is asking to SEE it
10
10
  * mmap [--window] [--ascii] [--no-color] [--no-mouse] [--no-follow]
11
11
  * [--interval <ms>] [--force]
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { existsSync } from 'node:fs';
30
30
  import { dirname, join, resolve } from 'node:path';
31
+ import { paneFailureMessage } from './watcher-command.mjs';
31
32
 
32
33
  import {
33
34
  DEDICATED_WINDOW_NAME,
@@ -35,9 +36,10 @@ import {
35
36
  launchedAsEntry,
36
37
  loadPluginPaths,
37
38
  placePane,
39
+ preparePane,
40
+ awaitNewPane,
38
41
  pluginRootOf,
39
42
  takeWatcherFlag,
40
- paneIsOpen,
41
43
  writeFocusRequest,
42
44
  writeQuitRequest,
43
45
  } from './pane-core.mjs';
@@ -168,37 +170,45 @@ async function main() {
168
170
  console.error(parsed.error);
169
171
  process.exit(1);
170
172
  }
171
- if (process.platform !== 'win32') {
172
- console.error('mmap opens the pane through Windows Terminal — on other platforms run the watcher yourself:');
173
- console.error(` node "${watchPath}" --file "<project>/${store.STATE_FILE_RELATIVE_PATH}"`);
174
- process.exit(1);
175
- }
176
-
177
- const candidates = storeSearchPath(process.cwd());
173
+ const candidates = [store.resolveProjectDirectory(process.cwd())];
178
174
  const marker = storeMarkerOf(store.STATE_FILE_RELATIVE_PATH);
179
175
  const project = nearestProject(candidates, candidates.map((dir) => existsSync(join(dir, marker))));
180
176
  const cfg = { ...parsed.value, projectDir: project.root };
181
177
  const mapFile = join(project.root, store.STATE_FILE_RELATIVE_PATH);
182
- const action = toggleAction(paneIsOpen(store, mapFile), cfg.pageSlug, cfg.force);
178
+ const fail = (error) => {
179
+ console.error(paneFailureMessage(error, cfg, watchPath, mapFile));
180
+ process.exit(1);
181
+ };
182
+ const prepared = preparePane(cfg, store, mapFile);
183
+ if (!prepared.ok) {
184
+ fail(prepared.error);
185
+ }
186
+ const context = prepared.value;
187
+ const action = toggleAction(context.viewer !== undefined, cfg.pageSlug, cfg.force);
183
188
 
184
189
  if (action.kind === 'quit') {
185
- writeQuitRequest(store.quitFilePath(mapFile));
190
+ writeQuitRequest(store.quitFilePath(mapFile, context.viewer.pid));
186
191
  console.log(`Closing the map pane for ${project.root}.`);
187
192
  return;
188
193
  }
189
194
  if (action.kind === 'focus') {
190
- writeFocusRequest(store.focusFilePath(mapFile), action.page);
195
+ writeFocusRequest(store.focusFilePath(mapFile, context.viewer.pid), action.page);
191
196
  console.log(`The map pane for ${project.root} is already open — showing page "${action.page}".`);
192
197
  return;
193
198
  }
194
199
 
195
- const placed = placePane(cfg, watchPath, mapFile);
200
+ const placed = placePane(cfg, watchPath, mapFile, context.target);
196
201
  if (!placed.ok) {
197
- console.error(placed.error);
198
- process.exit(1);
202
+ fail(placed.error);
203
+ }
204
+ const reported = await awaitNewPane(store, mapFile, context);
205
+ if (!reported.ok) {
206
+ fail(reported.error);
199
207
  }
200
208
  const where =
201
- placed.value.mode === PANE_MODE.window
209
+ placed.value.backend === 'tmux'
210
+ ? (placed.value.mode === PANE_MODE.window ? 'in a new tmux window' : 'beside this terminal (tmux split)')
211
+ : placed.value.mode === PANE_MODE.window
202
212
  ? `in the dedicated "${DEDICATED_WINDOW_NAME}" window (${placed.value.reason})`
203
213
  : 'beside this terminal (vertical split)';
204
214
  console.log(`Map opened for ${project.root} ${where}.`);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Open the live map pane in the RIGHT Windows Terminal window — the AGENT's
3
+ * Open the live map pane in the owning Windows Terminal or tmux session — the AGENT's
4
4
  * launcher, addressed by `/mmap` and by the skill.
5
5
  *
6
6
  * node scripts/open-pane.mjs <project-dir> [--page <slug>] [--window] [--force]
@@ -16,7 +16,7 @@
16
16
  * machine-readable report on top.
17
17
  *
18
18
  * --page <slug> opens the map ON that page (the effort under discussion, not
19
- * whatever page the store lists first). With a watcher already running it
19
+ * whatever page the store lists first). With a watcher owned by this console already running it
20
20
  * writes the one-shot focus file instead — the existing pane retargets within
21
21
  * a poll tick — so re-running with --page is also how you steer an open pane.
22
22
  * Every other flag belongs to the WATCHER and is forwarded verbatim; an
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import { existsSync } from 'node:fs';
33
33
  import { join, resolve } from 'node:path';
34
+ import { paneFailureMessage } from './watcher-command.mjs';
34
35
 
35
36
  import {
36
37
  DEDICATED_WINDOW_NAME,
@@ -38,9 +39,11 @@ import {
38
39
  launchedAsEntry,
39
40
  loadPluginPaths,
40
41
  placePane,
42
+ preparePane,
43
+ revealPane,
44
+ awaitNewPane,
41
45
  pluginRootOf,
42
46
  takeWatcherFlag,
43
- paneIsOpen,
44
47
  writeFocusRequest,
45
48
  } from './pane-core.mjs';
46
49
 
@@ -108,35 +111,51 @@ async function main() {
108
111
  console.error(`project directory does not exist: ${cfg.projectDir}`);
109
112
  process.exit(1);
110
113
  }
111
- if (process.platform !== 'win32') {
112
- console.error('open-pane.mjs is Windows Terminal-only — use the tmux/manual route from the command doc.');
113
- process.exit(1);
114
- }
115
-
116
114
  const mapFile = join(cfg.projectDir, store.STATE_FILE_RELATIVE_PATH);
117
115
 
118
- if (!cfg.force && paneIsOpen(store, mapFile)) {
116
+ const fail = (error) => {
117
+ console.error(paneFailureMessage(error, cfg, watchPath, mapFile));
118
+ process.exit(1);
119
+ };
120
+
121
+ const prepared = preparePane(cfg, store, mapFile);
122
+ if (!prepared.ok) {
123
+ fail(prepared.error);
124
+ }
125
+ const context = prepared.value;
126
+ if (!cfg.force && context.viewer) {
127
+ const visible = revealPane(context.target, context.viewer);
128
+ if (!visible.ok) fail(visible.error);
119
129
  if (cfg.pageSlug !== undefined) {
120
- writeFocusRequest(store.focusFilePath(mapFile), cfg.pageSlug);
121
- console.log(`MMAP_PANE already-open refocused=${cfg.pageSlug}`);
130
+ writeFocusRequest(store.focusFilePath(mapFile, context.viewer.pid), cfg.pageSlug);
131
+ console.log(`MMAP_PANE already-open pid=${context.viewer.pid} visibility=${visible.value} refocused=${cfg.pageSlug}`);
122
132
  console.log(`A watcher for ${mapFile} is already running — asked it to show page "${cfg.pageSlug}".`);
123
133
  } else {
124
- console.log('MMAP_PANE already-open');
134
+ console.log(`MMAP_PANE already-open pid=${context.viewer.pid} visibility=${visible.value}`);
125
135
  console.log(`A watcher for ${mapFile} is already running — not opening another pane (use --force to override).`);
126
136
  }
127
137
  process.exit(0);
128
138
  }
129
139
 
130
- const placed = placePane(cfg, watchPath, mapFile);
140
+ const placed = placePane(cfg, watchPath, mapFile, context.target);
131
141
  if (!placed.ok) {
132
- console.error(placed.error);
133
- process.exit(1);
142
+ fail(placed.error);
143
+ }
144
+ const reported = await awaitNewPane(store, mapFile, context);
145
+ if (!reported.ok) {
146
+ fail(reported.error);
134
147
  }
135
- if (placed.value.mode === PANE_MODE.window) {
136
- console.log(`MMAP_PANE mode=window name=${DEDICATED_WINDOW_NAME} reason=${placed.value.reason}`);
148
+ const visible = revealPane(context.target, reported.value);
149
+ if (!visible.ok) fail(visible.error);
150
+ if (placed.value.backend === 'tmux') {
151
+ console.log(`MMAP_PANE mode=${placed.value.mode} pid=${reported.value.pid} backend=tmux session=${placed.value.session} visibility=${visible.value}`);
152
+ console.log(placed.value.mode === PANE_MODE.window
153
+ ? 'Map opened in a new tmux window.' : 'Map opened beside this conversation (tmux split).');
154
+ } else if (placed.value.mode === PANE_MODE.window) {
155
+ console.log(`MMAP_PANE mode=window pid=${reported.value.pid} name=${DEDICATED_WINDOW_NAME} reason=${placed.value.reason}`);
137
156
  console.log(`Map opened in the dedicated "${DEDICATED_WINDOW_NAME}" window.`);
138
157
  } else {
139
- console.log(`MMAP_PANE mode=split hwnd=${placed.value.hwnd}`);
158
+ console.log(`MMAP_PANE mode=split pid=${reported.value.pid} hwnd=${placed.value.hwnd}`);
140
159
  console.log('Map opened beside this conversation (vertical split).');
141
160
  }
142
161
  }
@@ -29,7 +29,9 @@
29
29
  *
30
30
  * Pure helpers are exported for the spec; nothing here runs on import.
31
31
  */
32
- import { spawnSync } from 'node:child_process';
32
+ import * as terminal from './terminal-session.mjs';
33
+ import { createTmuxAdapter } from './tmux-session.mjs';
34
+ import { watcherArgs } from './watcher-command.mjs';
33
35
  import { existsSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
34
36
  import { dirname, join } from 'node:path';
35
37
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -185,234 +187,69 @@ export function writeQuitRequest(quitFile) {
185
187
 
186
188
  /** The `wt` payload that runs the watcher: the pane's title, cwd and command. */
187
189
  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;
190
+ return ['--title', 'mellos map', '-d', cfg.projectDir, 'node', ...watcherArgs(cfg, watchPath, mapFile)];
191
191
  }
192
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
- }
193
+ /** Explicitly requested independent window; default opens never fall back here. */
194
+ export const DEDICATED_WINDOW_NAME = 'mellos-mapping';
195
+ const SPLIT_SIZE = '0.42';
219
196
 
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
197
+ /** Pure reuse rule: a live viewer belongs to a console, not just to a project. */
198
+ export function selectPaneViewer(viewers, owners) {
199
+ return viewers.find((viewer) => viewer.owner !== undefined && owners.includes(viewer.owner));
265
200
  }
266
201
 
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
- }
202
+ const platformTerminal = () => process.platform === 'win32' ? terminal : createTmuxAdapter();
275
203
 
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
- }
204
+ /** Resolve identity before deciding whether to reuse, open or toggle. */
205
+ export function preparePane(cfg, store, mapFile, io = platformTerminal()) {
206
+ const viewers = store.readLiveViewers(mapFile, Date.now());
207
+ const inspected = io.kind === 'tmux' ? io.inspectSession(cfg, viewers) : cfg.mode === PANE_MODE.window
208
+ ? { ok: true, value: { owners: ['window'], owner: 'window' } }
209
+ : io.inspectSession();
210
+ if (!inspected.ok) return inspected;
211
+ const session = inspected.value;
212
+ const viewer = selectPaneViewer(viewers, session.owners);
213
+ if (io.kind !== 'tmux' && cfg.mode === PANE_MODE.split && !session.hwnd && (!viewer || cfg.force)) {
214
+ return { ok: false, error: 'Could not identify this conversation’s active Windows Terminal pane. Activate its PowerShell tab and retry; no separate window was opened. Use --window only if you want a separate window.' };
292
215
  }
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 }
216
+ return { ok: true, value: {
217
+ target: { ...session, mode: cfg.mode, owner: session.owner ?? viewer?.owner },
218
+ viewer,
219
+ previousPids: viewers.map((item) => item.pid),
220
+ } };
314
221
  }
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
222
 
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 ?? '';
223
+ /** A process heartbeat alone cannot establish that its pane is on screen. */
224
+ export function revealPane(target, viewer, io = platformTerminal()) {
225
+ return io.kind === 'tmux' ? io.revealPane(target, viewer) : { ok: true, value: 'unverified' };
346
226
  }
347
227
 
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;
228
+ /** Compose a verified target with the watcher payload. Never changes the requested mode. */
229
+ export function placePane(cfg, watchPath, mapFile, target, io = platformTerminal()) {
230
+ if (io.kind === 'tmux') return io.openPane(cfg, watchPath, mapFile, target);
231
+ const payload = [...paneCommand(cfg, watchPath, mapFile), '--owner', target.owner];
232
+ if (target.mode === PANE_MODE.window) {
233
+ const opened = io.openWt(['-w', DEDICATED_WINDOW_NAME, 'nt', ...payload], 'open the requested window');
234
+ return opened.ok ? { ok: true, value: { ...target, reason: 'requested' } } : opened;
235
+ }
236
+ const focused = io.focusSession(target.hwnd);
237
+ if (!focused.ok) return focused;
238
+ const opened = io.openWt([
239
+ '-w', '0', 'sp', '-V', '--size', SPLIT_SIZE, ...payload,
240
+ ';', 'move-focus', 'previous',
241
+ ], 'split the session window');
242
+ return opened.ok ? { ok: true, value: target } : opened;
243
+ }
244
+
245
+ /** Wait for the newly launched, correctly bound watcher rather than an older window. */
246
+ export async function awaitNewPane(store, mapFile, context, timeoutMs = 8_000) {
247
+ const deadline = Date.now() + timeoutMs;
248
+ do {
249
+ const viewer = store.readLiveViewers(mapFile, Date.now()).find((item) =>
250
+ item.owner === context.target.owner && !context.previousPids.includes(item.pid));
251
+ if (viewer) return { ok: true, value: viewer };
252
+ await new Promise((resolve) => setTimeout(resolve, 100));
253
+ } while (Date.now() < deadline);
254
+ return { ok: false, error: 'The requested pane has not reported in. Its placement is unverified; do not assume that another open map is the requested split.' };
418
255
  }
@@ -0,0 +1,137 @@
1
+ /** Windows Terminal adapter. No project paths, reuse policy or persistent state. */
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ const NATIVE = String.raw`
5
+ $ErrorActionPreference = 'Stop'
6
+ $ProgressPreference = 'SilentlyContinue'
7
+ Add-Type @"
8
+ using System;
9
+ using System.Text;
10
+ using System.Runtime.InteropServices;
11
+ public class MmapSession {
12
+ public delegate bool Callback(IntPtr hwnd, IntPtr lp);
13
+ [DllImport("user32.dll")] public static extern bool EnumWindows(Callback cb, IntPtr lp);
14
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
15
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, StringBuilder text, int size);
16
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int size);
17
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
18
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
19
+ [DllImport("kernel32.dll")] public static extern bool FreeConsole();
20
+ [DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
21
+ [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] list, uint size);
22
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern bool SetConsoleTitle(string title);
23
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern uint GetConsoleTitle(StringBuilder text, uint size);
24
+ }
25
+ "@
26
+ `;
27
+
28
+ /** A temporary title identifies the active session without guessing from titles or MRU order. */
29
+ export function sessionProbeScript(nonce) {
30
+ return NATIVE + String.raw`
31
+ $processes = @{}
32
+ Get-CimInstance Win32_Process | ForEach-Object { $processes[[uint32]$_.ProcessId] = $_ }
33
+ $cursor = [uint32]$PID
34
+ $seen = @{}
35
+ $hwnd = [IntPtr]::Zero
36
+ for ($depth = 0; $depth -lt 16 -and $processes.ContainsKey($cursor); $depth++) {
37
+ [void][MmapSession]::FreeConsole()
38
+ if ([MmapSession]::AttachConsole($cursor)) {
39
+ $members = New-Object uint32[] 4096
40
+ $count = [MmapSession]::GetConsoleProcessList($members, $members.Length)
41
+ if ($count -gt 0 -and $count -le $members.Length) {
42
+ $root = $members[0..($count - 1)] | ForEach-Object { $processes[$_] } |
43
+ Where-Object { $_ -and $_.CreationDate } | Sort-Object CreationDate | Select-Object -First 1
44
+ if ($root) {
45
+ $owner = "split-$($root.ProcessId)-$($root.CreationDate.ToUniversalTime().Ticks)"
46
+ if (-not $seen.ContainsKey($owner)) {
47
+ $seen[$owner] = $true
48
+ Write-Output "CONSOLE=$owner"
49
+ $text = New-Object System.Text.StringBuilder 1024
50
+ [void][MmapSession]::GetConsoleTitle($text, 1024)
51
+ $original = $text.ToString()
52
+ try {
53
+ for ($attempt = 0; $attempt -lt 6 -and $hwnd -eq [IntPtr]::Zero; $attempt++) {
54
+ [void][MmapSession]::SetConsoleTitle('__NONCE__')
55
+ Start-Sleep -Milliseconds 60
56
+ $matches = New-Object System.Collections.ArrayList
57
+ $callback = {
58
+ param($window, $unused)
59
+ if ([MmapSession]::IsWindowVisible($window)) {
60
+ $class = New-Object System.Text.StringBuilder 256
61
+ [void][MmapSession]::GetClassName($window, $class, 256)
62
+ if ($class.ToString() -eq 'CASCADIA_HOSTING_WINDOW_CLASS') {
63
+ $title = New-Object System.Text.StringBuilder 1024
64
+ [void][MmapSession]::GetWindowText($window, $title, 1024)
65
+ if ($title.ToString().Contains('__NONCE__')) { [void]$matches.Add($window) }
66
+ }
67
+ }
68
+ return $true
69
+ }
70
+ [void][MmapSession]::EnumWindows($callback, [IntPtr]::Zero)
71
+ if ($matches.Count -eq 1) { $hwnd = $matches[0] }
72
+ }
73
+ } finally {
74
+ [void][MmapSession]::SetConsoleTitle($original)
75
+ [void][MmapSession]::FreeConsole()
76
+ }
77
+ if ($hwnd -ne [IntPtr]::Zero) {
78
+ Write-Output "OWNER=$owner"
79
+ break
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+ $cursor = [uint32]$processes[$cursor].ParentProcessId
86
+ }
87
+ [void][MmapSession]::FreeConsole()
88
+ Write-Output "IDENT=$($hwnd.ToInt64())"
89
+ `.replaceAll('__NONCE__', nonce);
90
+ }
91
+
92
+ function powershell(script, timeout = 30_000) {
93
+ const result = spawnSync('powershell.exe', [
94
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand',
95
+ Buffer.from(script, 'utf16le').toString('base64'),
96
+ ], { encoding: 'utf8', windowsHide: true, timeout });
97
+ return result.status === 0
98
+ ? { ok: true, value: result.stdout }
99
+ : { ok: false, error: `Terminal session probe failed (${result.error?.message ?? `exit ${result.status}`}).` };
100
+ }
101
+
102
+ export function parseSessionProbe(output) {
103
+ const owners = [...output.matchAll(/^CONSOLE=(split-\d+-\d+)\r?$/gm)].map((m) => m[1]);
104
+ const hwnd = /^IDENT=([1-9]\d*)\r?$/m.exec(output)?.[1];
105
+ const owner = /^OWNER=(split-\d+-\d+)\r?$/m.exec(output)?.[1];
106
+ return { owners, ...(hwnd && owner && owners.includes(owner) ? { hwnd, owner } : {}) };
107
+ }
108
+
109
+ export function inspectSession() {
110
+ const result = powershell(sessionProbeScript(`MMAP-SESSION-${process.pid}-${Date.now()}`));
111
+ return result.ok ? { ok: true, value: parseSessionProbe(result.value) } : result;
112
+ }
113
+
114
+ /** WT's -w 0 is MRU-based, so it is only usable after verifying the exact foreground HWND. */
115
+ export function focusSession(hwnd) {
116
+ if (!/^[1-9]\d*$/.test(hwnd)) return { ok: false, error: 'Invalid terminal window handle.' };
117
+ const result = powershell(NATIVE + `
118
+ $window = [IntPtr]${hwnd}
119
+ if ([MmapSession]::GetForegroundWindow() -ne $window) {
120
+ [void][MmapSession]::SetForegroundWindow($window)
121
+ Start-Sleep -Milliseconds 150
122
+ }
123
+ Write-Output "FOCUS=$([MmapSession]::GetForegroundWindow() -eq $window)"
124
+ `, 5_000);
125
+ if (!result.ok) return result;
126
+ return /^FOCUS=True\r?$/m.test(result.value)
127
+ ? { ok: true, value: undefined }
128
+ : { ok: false, error: 'The session window was identified, but Windows refused to focus it. Activate the PowerShell tab for this conversation and retry; no separate window was opened.' };
129
+ }
130
+
131
+ export function openWt(args, what) {
132
+ // This is the requested interactive application. SW_HIDE also hides new WT windows.
133
+ const result = spawnSync('wt', args, { stdio: 'ignore', timeout: 15_000, windowsHide: false });
134
+ return result.status === 0
135
+ ? { ok: true, value: undefined }
136
+ : { ok: false, error: `wt failed to ${what} (${result.error?.message ?? `exit ${result.status}`}).` };
137
+ }