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.
Files changed (43) hide show
  1. package/README.md +360 -63
  2. package/README.zh-CN.md +314 -54
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1614 -809
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1391 -760
  8. package/lib/domain/ops.d.ts +71 -12
  9. package/lib/domain/ops.js +145 -14
  10. package/lib/domain/types.d.ts +47 -6
  11. package/lib/domain/types.js +34 -3
  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 +32 -46
  21. package/lib/render/render.js +58 -789
  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 +53 -4
  31. package/lib/semantics/semantics.js +130 -6
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +17 -0
  35. package/lib/store/format.js +185 -66
  36. package/lib/store/store.d.ts +220 -20
  37. package/lib/store/store.js +491 -38
  38. package/package.json +12 -4
  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
@@ -1,283 +1,144 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Open the live map pane in the RIGHT Windows Terminal window.
3
+ * Open the live map pane in the RIGHT Windows Terminal window — the AGENT's
4
+ * launcher, addressed by `/mmap` and by the skill.
4
5
  *
5
- * node scripts/open-pane.mjs <project-dir> [--page <slug>] [--window] [--ascii] [--force]
6
+ * node scripts/open-pane.mjs <project-dir> [--page <slug>] [--window] [--force]
7
+ * [--ascii] [--no-color] [--no-mouse] [--no-follow]
8
+ * [--interval <ms>]
6
9
  *
7
10
  * Why this exists: the agent's shell runs on a hidden console (no WT_SESSION),
8
11
  * so a bare `wt -w 0 sp` targets the MOST RECENTLY USED terminal window — with
9
12
  * several windows open the map lands wherever the user last clicked, not
10
- * beside the conversation. Verified on Windows 11 / WT single-process mode:
11
- * all windows share one WindowsTerminal.exe pid, and the console UI is
12
- * attached by default-terminal delegation, so neither pid-matching nor the
13
- * process tree can name the hosting window.
14
- *
15
- * What does work — and what this script does, verifying every step:
16
- *
17
- * 1. Walk this process's ancestors and AttachConsole to each in turn; write
18
- * a nonce into that console's title. The WT window whose title lights up
19
- * is the one hosting this session. The nonce only shows when the session
20
- * tab is the window's ACTIVE tab — exactly the precondition under which
21
- * `sp` would split beside the conversation, so identification doubles as
22
- * the go/no-go check.
23
- * 2. Bring that window to the foreground (plain SetForegroundWindow, then
24
- * the Alt-key unlock, then AttachThreadInput — Windows' foreground lock
25
- * denies the plain call from background processes), verifying with
26
- * GetForegroundWindow after each attempt.
27
- * 3. Only then `wt -w 0 sp` — "most recently used" is now provably ours.
28
- *
29
- * Any step failing falls back to a dedicated window named "mellos-mapping":
30
- * deterministic, never a random window. `--window` picks that mode outright
31
- * (explicit choice for users who want the map separate from the chat).
32
- *
33
- * Known race, accepted: if the user focuses a DIFFERENT terminal window in
34
- * the ~1s between our focus-verify and wt reading its MRU state, the split
35
- * can still land there. The window is at least one the user is actively in.
13
+ * beside the conversation. The window probe that solves it, the check for an
14
+ * already-running watcher and the `wt` payload live in ./pane-core.mjs, which
15
+ * documents each of them; this file is the command line, the policy and the
16
+ * machine-readable report on top.
36
17
  *
37
18
  * --page <slug> opens the map ON that page (the effort under discussion, not
38
19
  * whatever page the store lists first). With a watcher already running it
39
20
  * writes the one-shot focus file instead — the existing pane retargets within
40
21
  * a poll tick — so re-running with --page is also how you steer an open pane.
22
+ * Every other flag belongs to the WATCHER and is forwarded verbatim; an
23
+ * unknown flag is a usage error, never a silently dropped intention.
24
+ *
25
+ * What this launcher deliberately does NOT do is CLOSE a pane. An assistant
26
+ * asking for the map must not be able to take one away from the user; the
27
+ * toggle is the human's command, `mmap` (./mmap.mjs).
28
+ *
29
+ * Pure helpers are exported for the spec; the launcher runs only as an entry
30
+ * point, so importing this file is inert.
41
31
  */
42
- import { spawnSync } from 'node:child_process';
43
- import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
44
- import { dirname, join, resolve } from 'node:path';
45
- import { fileURLToPath } from 'node:url';
46
-
47
- const USAGE = 'usage: node scripts/open-pane.mjs <project-dir> [--page <slug>] [--window] [--ascii] [--force]';
48
-
49
- const argv = process.argv.slice(2);
50
- const flags = new Set();
51
- const positional = [];
52
- let pageSlug;
53
- for (let i = 0; i < argv.length; i++) {
54
- const a = argv[i];
55
- if (a === '--page') pageSlug = argv[++i];
56
- else if (a.startsWith('--')) flags.add(a);
57
- else positional.push(a);
58
- }
59
-
60
- if (positional.length !== 1) {
61
- console.error(USAGE);
62
- process.exit(1);
63
- }
64
- // Mirrors ID_RULE in src/domain/types.ts — this script runs standalone and
65
- // cannot import the TypeScript sources.
66
- if (pageSlug !== undefined && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(pageSlug)) {
67
- console.error(`--page needs a kebab-case slug (got "${pageSlug}")\n${USAGE}`);
68
- process.exit(1);
69
- }
70
- if (process.platform !== 'win32') {
71
- console.error('open-pane.mjs is Windows Terminal-only — use the tmux/manual route from the command doc.');
72
- process.exit(1);
73
- }
32
+ import { existsSync } from 'node:fs';
33
+ import { join, resolve } from 'node:path';
34
+
35
+ import {
36
+ DEDICATED_WINDOW_NAME,
37
+ PANE_MODE,
38
+ launchedAsEntry,
39
+ loadPluginPaths,
40
+ placePane,
41
+ pluginRootOf,
42
+ takeWatcherFlag,
43
+ paneIsOpen,
44
+ writeFocusRequest,
45
+ } from './pane-core.mjs';
46
+
47
+ export const USAGE =
48
+ 'usage: node scripts/open-pane.mjs <project-dir> [--page <slug>] [--window] [--force]' +
49
+ ' [--ascii] [--no-color] [--no-mouse] [--no-follow] [--interval <ms>]';
74
50
 
75
- const projectDir = resolve(positional[0]);
76
- if (!existsSync(projectDir)) {
77
- console.error(`project directory does not exist: ${projectDir}`);
78
- process.exit(1);
79
- }
80
-
81
- const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
82
- const watchPath = join(pluginRoot, 'dist', 'watch.mjs');
83
- if (!existsSync(watchPath)) {
84
- console.error(`watcher not found (is the plugin built?): ${watchPath}`);
85
- process.exit(1);
86
- }
87
- const mapFile = join(projectDir, '.mellos', 'map.json');
88
-
89
- function runPowerShell(script) {
90
- const encoded = Buffer.from(script, 'utf16le').toString('base64');
91
- const r = spawnSync(
92
- 'powershell.exe',
93
- ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
94
- { encoding: 'utf8', timeout: 30_000, windowsHide: true },
95
- );
96
- return r.stdout ?? '';
97
- }
98
-
99
- // One watcher per map file is enough — watch.mjs redraws on change for every
100
- // viewer of the same file, and piling up panes on repeated /mmap is noise.
101
- // Matches only watchers this script started (they carry --file <mapFile> on
102
- // their command line); --force bypasses.
103
- function watcherAlreadyRunning() {
104
- const token = mapFile.replace(/'/g, "''");
105
- const out = runPowerShell(
106
- `$ErrorActionPreference = 'SilentlyContinue'\n` +
107
- `$w = @(Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*watch.mjs*' -and $_.CommandLine -like '*${token}*' })\n` +
108
- `Write-Output "WATCHERS=$($w.Count)"`,
109
- );
110
- const m = out.match(/WATCHERS=(\d+)/);
111
- return m !== null && Number(m[1]) > 0;
112
- }
113
-
114
- // Prints IDENT=<hwnd|0> and, when identified, FOCUS=<1|0>.
115
- const IDENTIFY_AND_FOCUS = String.raw`
116
- $ErrorActionPreference = 'SilentlyContinue'
117
- Add-Type @"
118
- using System;
119
- using System.Text;
120
- using System.Runtime.InteropServices;
121
- public class MmapWin {
122
- [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lp);
123
- public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lp);
124
- [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
125
- [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
126
- [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
127
- [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
128
- [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
129
- [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd);
130
- [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
131
- [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
132
- [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
133
- [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo);
134
- [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
135
- [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
136
- [DllImport("kernel32.dll")] public static extern bool FreeConsole();
137
- [DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
138
- [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern bool SetConsoleTitle(string title);
139
- [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern uint GetConsoleTitle(StringBuilder sb, uint size);
140
- }
141
- "@
142
- function Get-WtWindows {
143
- $wins = New-Object System.Collections.ArrayList
144
- $cb = {
145
- param($h, $lp)
146
- if ([MmapWin]::IsWindowVisible($h)) {
147
- $cls = New-Object System.Text.StringBuilder 256
148
- [void][MmapWin]::GetClassName($h, $cls, 256)
149
- if ($cls.ToString() -eq 'CASCADIA_HOSTING_WINDOW_CLASS') {
150
- $t = New-Object System.Text.StringBuilder 512
151
- [void][MmapWin]::GetWindowText($h, $t, 512)
152
- [void]$wins.Add(@{ hwnd = $h.ToInt64(); title = $t.ToString() })
153
- }
51
+ /**
52
+ * Parse the launcher's command line.
53
+ *
54
+ * @param argv - arguments after the script path.
55
+ * @param idRule - the store's page-slug grammar (ID_RULE), passed in rather
56
+ * than restated, so this parser cannot drift from the ids the store accepts.
57
+ * @returns ok(config) or err(message) — a bad command line is an expected
58
+ * outcome of a hand-typed line, not an exception.
59
+ */
60
+ export function parsePaneArgs(argv, idRule) {
61
+ const positional = [];
62
+ const watcherFlags = [];
63
+ let pageSlug;
64
+ let mode = PANE_MODE.split;
65
+ let force = false;
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ const watcher = takeWatcherFlag(argv, i);
69
+ if (watcher.kind === 'bad-value') return { ok: false, error: `${watcher.message}\n${USAGE}` };
70
+ if (watcher.kind === 'taken') {
71
+ watcherFlags.push(...watcher.flags);
72
+ i = watcher.next;
73
+ } else if (a === '--page') {
74
+ pageSlug = argv[++i];
75
+ if (pageSlug === undefined) return { ok: false, error: `--page needs a slug\n${USAGE}` };
76
+ } else if (a === '--window') {
77
+ mode = PANE_MODE.window;
78
+ } else if (a === '--force') {
79
+ force = true;
80
+ } else if (a.startsWith('--')) {
81
+ return { ok: false, error: `unknown flag "${a}"\n${USAGE}` };
82
+ } else {
83
+ positional.push(a);
154
84
  }
155
- return $true
156
85
  }
157
- [void][MmapWin]::EnumWindows($cb, [IntPtr]::Zero)
158
- return ,$wins
86
+ if (positional.length !== 1) return { ok: false, error: USAGE };
87
+ if (pageSlug !== undefined && !idRule.test(pageSlug)) {
88
+ return { ok: false, error: `--page needs a kebab-case slug (got "${pageSlug}")\n${USAGE}` };
89
+ }
90
+ return { ok: true, value: { projectDir: resolve(positional[0]), pageSlug, mode, force, watcherFlags } };
159
91
  }
160
92
 
161
- $ancestors = @()
162
- $p = $PID
163
- for ($i = 0; $i -lt 12 -and $p; $i++) {
164
- $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$p"
165
- if (-not $proc) { break }
166
- if ($i -gt 0) { $ancestors += [uint32]$proc.ProcessId }
167
- $p = $proc.ParentProcessId
168
- }
93
+ async function main() {
94
+ const loaded = await loadPluginPaths(pluginRootOf(import.meta.url));
95
+ if (!loaded.ok) {
96
+ console.error(loaded.error);
97
+ process.exit(1);
98
+ }
99
+ const { store, watchPath } = loaded.value;
169
100
 
170
- # The agent CLI (claude/codex/...) is some ancestor holding the console that a
171
- # WT window renders; hidden-console ancestors just never light a window up.
172
- $nonce = "__NONCE__"
173
- $hwnd = [IntPtr]::Zero
174
- foreach ($apid in $ancestors) {
175
- [void][MmapWin]::FreeConsole()
176
- if (-not [MmapWin]::AttachConsole($apid)) { continue }
177
- $sb = New-Object System.Text.StringBuilder 1024
178
- [void][MmapWin]::GetConsoleTitle($sb, 1024)
179
- $orig = $sb.ToString()
180
- for ($i = 0; $i -lt 6 -and $hwnd -eq [IntPtr]::Zero; $i++) {
181
- [void][MmapWin]::SetConsoleTitle($nonce)
182
- Start-Sleep -Milliseconds 60
183
- foreach ($w in (Get-WtWindows)) {
184
- if ($w.title -like "*$nonce*") { $hwnd = [IntPtr]$w.hwnd; break }
185
- }
101
+ const parsed = parsePaneArgs(process.argv.slice(2), store.ID_RULE);
102
+ if (!parsed.ok) {
103
+ console.error(parsed.error);
104
+ process.exit(1);
105
+ }
106
+ const cfg = parsed.value;
107
+ if (!existsSync(cfg.projectDir)) {
108
+ console.error(`project directory does not exist: ${cfg.projectDir}`);
109
+ process.exit(1);
110
+ }
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);
186
114
  }
187
- Start-Sleep -Milliseconds 100
188
- [void][MmapWin]::SetConsoleTitle($orig)
189
- Start-Sleep -Milliseconds 200
190
- [void][MmapWin]::FreeConsole()
191
- if ($hwnd -ne [IntPtr]::Zero) { break }
192
- }
193
115
 
194
- if ($hwnd -eq [IntPtr]::Zero) { Write-Output 'IDENT=0'; exit 0 }
195
- Write-Output "IDENT=$($hwnd.ToInt64())"
116
+ const mapFile = join(cfg.projectDir, store.STATE_FILE_RELATIVE_PATH);
196
117
 
197
- if ([MmapWin]::IsIconic($hwnd)) { [void][MmapWin]::ShowWindow($hwnd, 9) }
198
- $focused = $false
199
- [void][MmapWin]::SetForegroundWindow($hwnd)
200
- Start-Sleep -Milliseconds 150
201
- if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
202
- if (-not $focused) {
203
- [MmapWin]::keybd_event(0x12, 0, 0, [IntPtr]::Zero)
204
- [void][MmapWin]::SetForegroundWindow($hwnd)
205
- [MmapWin]::keybd_event(0x12, 0, 2, [IntPtr]::Zero)
206
- Start-Sleep -Milliseconds 150
207
- if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
208
- }
209
- if (-not $focused) {
210
- $fgpid = 0
211
- $fgThread = [MmapWin]::GetWindowThreadProcessId([MmapWin]::GetForegroundWindow(), [ref]$fgpid)
212
- $myThread = [MmapWin]::GetCurrentThreadId()
213
- [void][MmapWin]::AttachThreadInput($myThread, $fgThread, $true)
214
- [void][MmapWin]::BringWindowToTop($hwnd)
215
- [void][MmapWin]::SetForegroundWindow($hwnd)
216
- [void][MmapWin]::AttachThreadInput($myThread, $fgThread, $false)
217
- Start-Sleep -Milliseconds 150
218
- if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
219
- }
220
- Write-Output "FOCUS=$(if ($focused) { 1 } else { 0 })"
221
- `;
222
-
223
- function paneCommand() {
224
- const cmd = ['--title', 'mellos map', '-d', projectDir, 'node', watchPath, '--file', mapFile];
225
- if (flags.has('--ascii')) cmd.push('--ascii');
226
- if (pageSlug !== undefined) cmd.push('--page', pageSlug);
227
- return cmd;
228
- }
118
+ if (!cfg.force && paneIsOpen(store, mapFile)) {
119
+ if (cfg.pageSlug !== undefined) {
120
+ writeFocusRequest(store.focusFilePath(mapFile), cfg.pageSlug);
121
+ console.log(`MMAP_PANE already-open refocused=${cfg.pageSlug}`);
122
+ console.log(`A watcher for ${mapFile} is already running — asked it to show page "${cfg.pageSlug}".`);
123
+ } else {
124
+ console.log('MMAP_PANE already-open');
125
+ console.log(`A watcher for ${mapFile} is already running — not opening another pane (use --force to override).`);
126
+ }
127
+ process.exit(0);
128
+ }
229
129
 
230
- function openWt(args, what) {
231
- const r = spawnSync('wt', args, { stdio: 'ignore', timeout: 15_000, windowsHide: true });
232
- if (r.status !== 0) {
233
- console.error(`wt failed to ${what} (exit ${r.status ?? 'timeout'}) — is Windows Terminal installed?`);
130
+ const placed = placePane(cfg, watchPath, mapFile);
131
+ if (!placed.ok) {
132
+ console.error(placed.error);
234
133
  process.exit(1);
235
134
  }
236
- }
237
-
238
- function openDedicatedWindow(reason) {
239
- openWt(['-w', 'mellos-mapping', 'nt', ...paneCommand()], 'open the dedicated window');
240
- console.log(`MMAP_PANE mode=window name=mellos-mapping reason=${reason}`);
241
- console.log('Map opened in the dedicated "mellos-mapping" window.');
242
- }
243
-
244
- if (!flags.has('--force') && watcherAlreadyRunning()) {
245
- if (pageSlug !== undefined) {
246
- // One-shot focus request (see takeFocusRequest in src/store/store.ts):
247
- // the running watcher consumes and deletes it within a poll tick. Temp +
248
- // rename because the watcher polls: a torn read would be swept as junk,
249
- // silently losing the request.
250
- mkdirSync(dirname(mapFile), { recursive: true });
251
- const focusFile = join(dirname(mapFile), 'mellos-mapping.focus');
252
- writeFileSync(`${focusFile}.tmp`, JSON.stringify({ page: pageSlug }));
253
- renameSync(`${focusFile}.tmp`, focusFile);
254
- console.log(`MMAP_PANE already-open refocused=${pageSlug}`);
255
- console.log(`A watcher for ${mapFile} is already running — asked it to show page "${pageSlug}".`);
135
+ if (placed.value.mode === PANE_MODE.window) {
136
+ console.log(`MMAP_PANE mode=window name=${DEDICATED_WINDOW_NAME} reason=${placed.value.reason}`);
137
+ console.log(`Map opened in the dedicated "${DEDICATED_WINDOW_NAME}" window.`);
256
138
  } else {
257
- console.log('MMAP_PANE already-open');
258
- console.log(`A watcher for ${mapFile} is already running — not opening another pane (use --force to override).`);
139
+ console.log(`MMAP_PANE mode=split hwnd=${placed.value.hwnd}`);
140
+ console.log('Map opened beside this conversation (vertical split).');
259
141
  }
260
- process.exit(0);
261
- }
262
-
263
- if (flags.has('--window')) {
264
- openDedicatedWindow('requested');
265
- process.exit(0);
266
142
  }
267
143
 
268
- const nonce = `MMAP-NONCE-${process.pid}`;
269
- const out = runPowerShell(IDENTIFY_AND_FOCUS.replaceAll('__NONCE__', nonce));
270
- const ident = out.match(/IDENT=(\d+)/)?.[1] ?? '0';
271
- const focused = /FOCUS=1/.test(out);
272
-
273
- if (ident === '0') {
274
- openDedicatedWindow('session-window-not-identified');
275
- } else if (!focused) {
276
- openDedicatedWindow('session-window-focus-denied');
277
- } else {
278
- // The identified window is foreground right now, so "most recently used"
279
- // is deterministically it (see the race note in the header).
280
- openWt(['-w', '0', 'sp', '-V', '--size', '0.42', ...paneCommand()], 'split the session window');
281
- console.log(`MMAP_PANE mode=split hwnd=${ident}`);
282
- console.log('Map opened beside this conversation (vertical split).');
283
- }
144
+ if (launchedAsEntry(process.argv[1], import.meta.url)) await main();