mellos-mapping 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/README.zh-CN.md +8 -3
- package/dist/server.mjs +1 -1
- package/dist/watch.mjs +143 -1
- package/package.json +3 -2
- package/scripts/open-pane.mjs +249 -0
package/README.md
CHANGED
|
@@ -125,7 +125,10 @@ session's working directory instead: the state file lands in your project,
|
|
|
125
125
|
same as under Claude Code. The registered path is version-specific — re-run
|
|
126
126
|
the script after updating the plugin.
|
|
127
127
|
|
|
128
|
-
To watch the live pane beside a Codex session, run
|
|
128
|
+
To watch the live pane beside a Codex session on Windows, run
|
|
129
|
+
`node <plugin root>/scripts/open-pane.mjs <project dir>` — it splits the
|
|
130
|
+
terminal window hosting the session (or falls back to a dedicated
|
|
131
|
+
"mellos-mapping" window; `--window` picks that on purpose). Elsewhere run
|
|
129
132
|
`node <plugin root>/dist/watch.mjs` from the project directory in a second
|
|
130
133
|
terminal (or any terminal split).
|
|
131
134
|
|
|
@@ -154,7 +157,10 @@ get the four `mmap_*` tools and the pane, and bring their own prompting.
|
|
|
154
157
|
declare the ghost design and keep the map current as it works.
|
|
155
158
|
2. Run `/mellos-mapping:mmap` to open the live pane (Windows Terminal split
|
|
156
159
|
on Windows, tmux split inside tmux, or a printed command to run in any
|
|
157
|
-
second terminal).
|
|
160
|
+
second terminal). On Windows the pane opens in the terminal window
|
|
161
|
+
hosting YOUR session, even with several windows open; pass `--window` to
|
|
162
|
+
put the map in its own dedicated window instead. Prefer `--ascii` if
|
|
163
|
+
your font lacks box-drawing glyphs.
|
|
158
164
|
3. Watch nodes light up from the bottom. Interrupt when the picture worries
|
|
159
165
|
you — that is what it is for.
|
|
160
166
|
|
package/README.zh-CN.md
CHANGED
|
@@ -115,8 +115,11 @@ node ~/.codex/plugins/cache/mellos-mapping/mellos-mapping/<版本>/scripts/codex
|
|
|
115
115
|
项目里,与 Claude Code 下行为一致。注册的是版本相关的绝对路径——插件
|
|
116
116
|
更新后重跑一次脚本即可。
|
|
117
117
|
|
|
118
|
-
要在 Codex
|
|
119
|
-
|
|
118
|
+
要在 Codex 会话旁边看实况面板,Windows 上运行
|
|
119
|
+
`node <插件根>/scripts/open-pane.mjs <项目目录>`——它会在承载本会话的
|
|
120
|
+
终端窗口里分屏(识别不到就确定性地开到专属的 "mellos-mapping" 窗口;
|
|
121
|
+
`--window` 则是主动选择专属窗口)。其他环境在项目目录下的第二个终端
|
|
122
|
+
(或任意分屏)运行 `node <插件根>/dist/watch.mjs`。
|
|
120
123
|
|
|
121
124
|
## 任意 MCP 客户端
|
|
122
125
|
|
|
@@ -143,7 +146,9 @@ npx -y -p mellos-mapping mellos-mapping-watch
|
|
|
143
146
|
设计,并在工作过程中保持地图与现实一致。
|
|
144
147
|
2. 运行 `/mellos-mapping:mmap` 打开实况分屏(Windows 上是 Windows
|
|
145
148
|
Terminal 分屏,tmux 里是 tmux 分屏,其他环境会打印一条命令让你在
|
|
146
|
-
|
|
149
|
+
第二个终端里运行)。Windows 上即使开着多个终端窗口,分屏也会落在
|
|
150
|
+
**你的会话所在的窗口**;想让地图独占一个窗口就加 `--window`。字体
|
|
151
|
+
缺少制表符字形时用 `--ascii`。
|
|
147
152
|
3. 看着节点从底部一路亮起。图让你不安的时候就打断它——这正是它存在的
|
|
148
153
|
意义。
|
|
149
154
|
|
package/dist/server.mjs
CHANGED
|
@@ -22513,7 +22513,7 @@ function summarize(map) {
|
|
|
22513
22513
|
|
|
22514
22514
|
// src/server/server.ts
|
|
22515
22515
|
var SERVER_NAME = "mellos-mapping";
|
|
22516
|
-
var SERVER_VERSION = "0.
|
|
22516
|
+
var SERVER_VERSION = "0.14.0";
|
|
22517
22517
|
var ID = external_exports.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, "lowercase letters, digits and dashes, 1-64 chars").describe("stable kebab-case identifier");
|
|
22518
22518
|
var PAGE = ID.optional().describe(
|
|
22519
22519
|
"page (parallel map) this call targets; omit for the default page. One effort = one page: start a NEW effort on its own page named after the effort, so concurrent sessions never write over each other and the pane can switch between pages."
|
package/dist/watch.mjs
CHANGED
|
@@ -1442,6 +1442,131 @@ function nodePanel(map, focusId, unicode, width, pinned, rows = PANEL_CONTENT_RO
|
|
|
1442
1442
|
}
|
|
1443
1443
|
return lines.slice(0, rows);
|
|
1444
1444
|
}
|
|
1445
|
+
var SPLASH_FONT = {
|
|
1446
|
+
M: ["# #", "## ##", "# # #", "# #", "# #"],
|
|
1447
|
+
E: ["####", "#", "###", "#", "####"],
|
|
1448
|
+
L: ["#", "#", "#", "#", "####"],
|
|
1449
|
+
O: [" ###", "# #", "# #", "# #", " ###"],
|
|
1450
|
+
S: [" ####", "#", " ###", " #", "####"],
|
|
1451
|
+
A: [" ###", "# #", "#####", "# #", "# #"],
|
|
1452
|
+
P: ["####", "# #", "####", "#", "#"],
|
|
1453
|
+
I: ["###", " #", " #", " #", "###"],
|
|
1454
|
+
N: ["# #", "## #", "# # #", "# ##", "# #"],
|
|
1455
|
+
G: [" ####", "#", "# ##", "# #", " ###"]
|
|
1456
|
+
};
|
|
1457
|
+
var SPLASH_ROWS = 5;
|
|
1458
|
+
var SPLASH_SHADES = {
|
|
1459
|
+
unicode: ["\u2591", "\u2591", "\u2592", "\u2592", "\u2593", "\u2593", "\u2588", "\u2588"],
|
|
1460
|
+
ascii: [".", ".", ":", ":", "=", "=", "#", "#"]
|
|
1461
|
+
};
|
|
1462
|
+
var WAVE_RAMP = [17, 18, 19, 61, 24, 25, 31, 37, 44, 45, 51, 87, 123, 159, 195];
|
|
1463
|
+
var SPINNER_FRAMES = {
|
|
1464
|
+
unicode: ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"],
|
|
1465
|
+
ascii: ["|", "/", "-", "\\"]
|
|
1466
|
+
};
|
|
1467
|
+
function wordArt(word) {
|
|
1468
|
+
const glyphs = [...word.toUpperCase()].map((ch) => SPLASH_FONT[ch]).filter((g) => g !== void 0);
|
|
1469
|
+
const widths = glyphs.map((g) => Math.max(...g.map((r) => r.length)));
|
|
1470
|
+
const rows = [];
|
|
1471
|
+
for (let r = 0; r < SPLASH_ROWS; r++) {
|
|
1472
|
+
rows.push(glyphs.map((g, i) => (g[r] ?? "").padEnd(widths[i], " ")).join(" "));
|
|
1473
|
+
}
|
|
1474
|
+
return rows;
|
|
1475
|
+
}
|
|
1476
|
+
function splashArt() {
|
|
1477
|
+
const words = [wordArt("MELLOS"), wordArt("MAPPING")];
|
|
1478
|
+
const width = Math.max(...words.flat().map((r) => r.length));
|
|
1479
|
+
const centered = words.map((rows) => {
|
|
1480
|
+
const own = Math.max(...rows.map((r) => r.length));
|
|
1481
|
+
return rows.map((r) => " ".repeat(Math.floor((width - own) / 2)) + r);
|
|
1482
|
+
});
|
|
1483
|
+
return [...centered[0], "", ...centered[1]];
|
|
1484
|
+
}
|
|
1485
|
+
var WAVE_INTERVAL = 18;
|
|
1486
|
+
var WAVE_LIFETIME = 64;
|
|
1487
|
+
var WAVE_SPEED = 0.9;
|
|
1488
|
+
var WAVE_ENVELOPE = 10;
|
|
1489
|
+
var WAVE_NUMBER = 0.42;
|
|
1490
|
+
var WAVE_LEVELS = 7;
|
|
1491
|
+
var WAVE_GAIN = 4.5;
|
|
1492
|
+
function waveHash(n) {
|
|
1493
|
+
let h = Math.imul(n + 1, 2654435761) >>> 0;
|
|
1494
|
+
h ^= h >>> 15;
|
|
1495
|
+
h = Math.imul(h, 2246822519) >>> 0;
|
|
1496
|
+
h ^= h >>> 13;
|
|
1497
|
+
return h >>> 0;
|
|
1498
|
+
}
|
|
1499
|
+
var WAVE_CORNERS = [
|
|
1500
|
+
[0, 0],
|
|
1501
|
+
[1, 0],
|
|
1502
|
+
[0, 1],
|
|
1503
|
+
[1, 1]
|
|
1504
|
+
];
|
|
1505
|
+
function liveRipples(frame, width, height) {
|
|
1506
|
+
const out = [];
|
|
1507
|
+
const first = Math.floor((frame - WAVE_LIFETIME - WAVE_INTERVAL) / WAVE_INTERVAL);
|
|
1508
|
+
const last = Math.floor(frame / WAVE_INTERVAL);
|
|
1509
|
+
for (let n = Math.max(0, first); n <= last; n++) {
|
|
1510
|
+
const h = waveHash(n);
|
|
1511
|
+
const age = frame - (n * WAVE_INTERVAL + h % WAVE_INTERVAL);
|
|
1512
|
+
if (age < 0 || age > WAVE_LIFETIME) continue;
|
|
1513
|
+
const [fx, fy] = WAVE_CORNERS[h % WAVE_CORNERS.length];
|
|
1514
|
+
out.push({
|
|
1515
|
+
ox: fx * (width - 1),
|
|
1516
|
+
oy: fy * (height - 1),
|
|
1517
|
+
r: age * WAVE_SPEED,
|
|
1518
|
+
fade: 1 - age / WAVE_LIFETIME
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
return out;
|
|
1522
|
+
}
|
|
1523
|
+
function waveAt(ripples, x, y) {
|
|
1524
|
+
let value = 0;
|
|
1525
|
+
for (const w of ripples) {
|
|
1526
|
+
const front = Math.hypot(x - w.ox, (y - w.oy) * 2) - w.r;
|
|
1527
|
+
value += Math.cos(front * WAVE_NUMBER) * Math.exp(-(front * front) / (2 * WAVE_ENVELOPE ** 2)) * w.fade;
|
|
1528
|
+
}
|
|
1529
|
+
return value;
|
|
1530
|
+
}
|
|
1531
|
+
function waveLevel(value) {
|
|
1532
|
+
return Math.max(-WAVE_LEVELS, Math.min(WAVE_LEVELS, Math.round(value * WAVE_GAIN)));
|
|
1533
|
+
}
|
|
1534
|
+
function splashFrame(notice, frame, width, height, unicode, color) {
|
|
1535
|
+
const art = splashArt();
|
|
1536
|
+
const artWidth = Math.max(...art.map((r) => r.length));
|
|
1537
|
+
if (width < artWidth + 2 || height < art.length + 2) return void 0;
|
|
1538
|
+
const mode = unicode ? "unicode" : "ascii";
|
|
1539
|
+
const shades = SPLASH_SHADES[mode];
|
|
1540
|
+
const solid = shades[shades.length - 1];
|
|
1541
|
+
const indent = " ".repeat(Math.floor((width - artWidth) / 2));
|
|
1542
|
+
const ripples = liveRipples(frame, artWidth, art.length);
|
|
1543
|
+
const inkOf = (x, y) => {
|
|
1544
|
+
const level = waveLevel(waveAt(ripples, x, y));
|
|
1545
|
+
return color ? `38;5;${WAVE_RAMP[WAVE_LEVELS + level]}` : shades[Math.abs(level)];
|
|
1546
|
+
};
|
|
1547
|
+
const paintRow = (row, y) => {
|
|
1548
|
+
const cells = [...row].map((ch, x) => ch === "#" ? inkOf(x, y) : void 0);
|
|
1549
|
+
let out = "";
|
|
1550
|
+
for (let i = 0; i < cells.length; ) {
|
|
1551
|
+
const cell = cells[i];
|
|
1552
|
+
let j = i;
|
|
1553
|
+
while (j < cells.length && cells[j] === cell) j++;
|
|
1554
|
+
if (cell === void 0) out += " ".repeat(j - i);
|
|
1555
|
+
else out += color ? `\x1B[${cell}m${solid.repeat(j - i)}${RESET}` : cell.repeat(j - i);
|
|
1556
|
+
i = j;
|
|
1557
|
+
}
|
|
1558
|
+
return out;
|
|
1559
|
+
};
|
|
1560
|
+
const spinner = SPINNER_FRAMES[mode];
|
|
1561
|
+
const status = fitWidth(`${spinner[frame % spinner.length]} ${notice}`, Math.max(1, width - 2));
|
|
1562
|
+
const statusIndent = " ".repeat(Math.max(0, Math.floor((width - displayWidth(status)) / 2)));
|
|
1563
|
+
const block = [
|
|
1564
|
+
...art.map((row, y) => row.trim() === "" ? "" : indent + paintRow(row, y)),
|
|
1565
|
+
"",
|
|
1566
|
+
statusIndent + (color ? `\x1B[90m${status}${RESET}` : status)
|
|
1567
|
+
];
|
|
1568
|
+
return [...Array.from({ length: Math.max(0, Math.floor((height - block.length) / 2)) }, () => ""), ...block];
|
|
1569
|
+
}
|
|
1445
1570
|
function mapPanel(map, unicode, width, rows = PANEL_CONTENT_ROWS) {
|
|
1446
1571
|
const g = (s) => STATUS_GLYPH[s][unicode ? 0 : 1];
|
|
1447
1572
|
const count = (s) => map.nodes.filter((n) => n.status === s).length;
|
|
@@ -1466,6 +1591,7 @@ function main() {
|
|
|
1466
1591
|
const mouseActive = interactive && cfg.mouse;
|
|
1467
1592
|
let lastFrame = "";
|
|
1468
1593
|
let spinnerFrame = 0;
|
|
1594
|
+
let splashTick = 0;
|
|
1469
1595
|
let map;
|
|
1470
1596
|
let notice = `waiting for ${cfg.file} ...`;
|
|
1471
1597
|
let lastCols = process.stdout.columns ?? 0;
|
|
@@ -1567,7 +1693,7 @@ function main() {
|
|
|
1567
1693
|
lastContent = { w: windowed.contentWidth, h: windowed.contentHeight };
|
|
1568
1694
|
if (offsetX !== 0 || offsetY !== 0) panned = ` (+${offsetX},+${offsetY})`;
|
|
1569
1695
|
} else {
|
|
1570
|
-
body = [fitWidth(notice, Math.max(1, cols - 1))];
|
|
1696
|
+
body = (interactive ? splashFrame(notice, splashTick, cols, viewH, cfg.unicode, cfg.color) : void 0) ?? [fitWidth(notice, Math.max(1, cols - 1))];
|
|
1571
1697
|
}
|
|
1572
1698
|
if (notice !== "" && map !== void 0) {
|
|
1573
1699
|
body[body.length - 1] = fitWidth(` ${notice}`, Math.max(1, cols - 1));
|
|
@@ -1854,6 +1980,13 @@ function main() {
|
|
|
1854
1980
|
}
|
|
1855
1981
|
tick();
|
|
1856
1982
|
setInterval(tick, cfg.intervalMs);
|
|
1983
|
+
if (interactive) {
|
|
1984
|
+
setInterval(() => {
|
|
1985
|
+
if (map !== void 0) return;
|
|
1986
|
+
splashTick++;
|
|
1987
|
+
paint();
|
|
1988
|
+
}, 80);
|
|
1989
|
+
}
|
|
1857
1990
|
}
|
|
1858
1991
|
function launchedAsEntry(argv1, moduleUrl) {
|
|
1859
1992
|
if (argv1 === void 0) return false;
|
|
@@ -1868,17 +2001,26 @@ if (launchedAsEntry(process.argv[1], import.meta.url)) {
|
|
|
1868
2001
|
}
|
|
1869
2002
|
export {
|
|
1870
2003
|
PANEL_ROWS_MIN,
|
|
2004
|
+
WAVE_LEVELS,
|
|
2005
|
+
WAVE_NUMBER,
|
|
1871
2006
|
anchorOffsets,
|
|
1872
2007
|
clampPanelRows,
|
|
1873
2008
|
diveOrigin,
|
|
1874
2009
|
fitWidth,
|
|
1875
2010
|
launchedAsEntry,
|
|
2011
|
+
liveRipples,
|
|
1876
2012
|
mapPanel,
|
|
1877
2013
|
nearestHit,
|
|
1878
2014
|
nodePanel,
|
|
1879
2015
|
pageTabRow,
|
|
1880
2016
|
panelRowsFromDividerY,
|
|
1881
2017
|
parseArgs,
|
|
2018
|
+
splashArt,
|
|
2019
|
+
splashFrame,
|
|
1882
2020
|
topLevelFiles,
|
|
2021
|
+
waveAt,
|
|
2022
|
+
waveHash,
|
|
2023
|
+
waveLevel,
|
|
2024
|
+
wordArt,
|
|
1883
2025
|
wrapWidth
|
|
1884
2026
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mellos-mapping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"mcpName": "io.github.GuangminJu/mellos-mapping",
|
|
5
5
|
"description": "A live layered dependency map for bottom-up development — MCP server + terminal pane. Ghost the design first, then light nodes up from the bottom as they are built and verified.",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"dist",
|
|
35
35
|
"README.zh-CN.md",
|
|
36
|
-
"scripts/codex-register.mjs"
|
|
36
|
+
"scripts/codex-register.mjs",
|
|
37
|
+
"scripts/open-pane.mjs"
|
|
37
38
|
],
|
|
38
39
|
"engines": {
|
|
39
40
|
"node": ">=18"
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Open the live map pane in the RIGHT Windows Terminal window.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/open-pane.mjs <project-dir> [--window] [--ascii] [--force]
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: the agent's shell runs on a hidden console (no WT_SESSION),
|
|
8
|
+
* so a bare `wt -w 0 sp` targets the MOST RECENTLY USED terminal window — with
|
|
9
|
+
* 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.
|
|
36
|
+
*/
|
|
37
|
+
import { spawnSync } from 'node:child_process';
|
|
38
|
+
import { existsSync } from 'node:fs';
|
|
39
|
+
import { dirname, join, resolve } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
|
44
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
45
|
+
|
|
46
|
+
if (positional.length !== 1) {
|
|
47
|
+
console.error('usage: node scripts/open-pane.mjs <project-dir> [--window] [--ascii] [--force]');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
if (process.platform !== 'win32') {
|
|
51
|
+
console.error('open-pane.mjs is Windows Terminal-only — use the tmux/manual route from the command doc.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const projectDir = resolve(positional[0]);
|
|
56
|
+
if (!existsSync(projectDir)) {
|
|
57
|
+
console.error(`project directory does not exist: ${projectDir}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
62
|
+
const watchPath = join(pluginRoot, 'dist', 'watch.mjs');
|
|
63
|
+
if (!existsSync(watchPath)) {
|
|
64
|
+
console.error(`watcher not found (is the plugin built?): ${watchPath}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const mapFile = join(projectDir, '.claude', 'mellos-mapping.json');
|
|
68
|
+
|
|
69
|
+
function runPowerShell(script) {
|
|
70
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
71
|
+
const r = spawnSync(
|
|
72
|
+
'powershell.exe',
|
|
73
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
|
74
|
+
{ encoding: 'utf8', timeout: 30_000, windowsHide: true },
|
|
75
|
+
);
|
|
76
|
+
return r.stdout ?? '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// One watcher per map file is enough — watch.mjs redraws on change for every
|
|
80
|
+
// viewer of the same file, and piling up panes on repeated /mmap is noise.
|
|
81
|
+
// Matches only watchers this script started (they carry --file <mapFile> on
|
|
82
|
+
// their command line); --force bypasses.
|
|
83
|
+
function watcherAlreadyRunning() {
|
|
84
|
+
const token = mapFile.replace(/'/g, "''");
|
|
85
|
+
const out = runPowerShell(
|
|
86
|
+
`$ErrorActionPreference = 'SilentlyContinue'\n` +
|
|
87
|
+
`$w = @(Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*watch.mjs*' -and $_.CommandLine -like '*${token}*' })\n` +
|
|
88
|
+
`Write-Output "WATCHERS=$($w.Count)"`,
|
|
89
|
+
);
|
|
90
|
+
const m = out.match(/WATCHERS=(\d+)/);
|
|
91
|
+
return m !== null && Number(m[1]) > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Prints IDENT=<hwnd|0> and, when identified, FOCUS=<1|0>.
|
|
95
|
+
const IDENTIFY_AND_FOCUS = String.raw`
|
|
96
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
97
|
+
Add-Type @"
|
|
98
|
+
using System;
|
|
99
|
+
using System.Text;
|
|
100
|
+
using System.Runtime.InteropServices;
|
|
101
|
+
public class MmapWin {
|
|
102
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lp);
|
|
103
|
+
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lp);
|
|
104
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
105
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max);
|
|
106
|
+
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
|
|
107
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
108
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
109
|
+
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd);
|
|
110
|
+
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
|
|
111
|
+
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
|
112
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
113
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo);
|
|
114
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
|
115
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
|
|
116
|
+
[DllImport("kernel32.dll")] public static extern bool FreeConsole();
|
|
117
|
+
[DllImport("kernel32.dll")] public static extern bool AttachConsole(uint pid);
|
|
118
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern bool SetConsoleTitle(string title);
|
|
119
|
+
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)] public static extern uint GetConsoleTitle(StringBuilder sb, uint size);
|
|
120
|
+
}
|
|
121
|
+
"@
|
|
122
|
+
function Get-WtWindows {
|
|
123
|
+
$wins = New-Object System.Collections.ArrayList
|
|
124
|
+
$cb = {
|
|
125
|
+
param($h, $lp)
|
|
126
|
+
if ([MmapWin]::IsWindowVisible($h)) {
|
|
127
|
+
$cls = New-Object System.Text.StringBuilder 256
|
|
128
|
+
[void][MmapWin]::GetClassName($h, $cls, 256)
|
|
129
|
+
if ($cls.ToString() -eq 'CASCADIA_HOSTING_WINDOW_CLASS') {
|
|
130
|
+
$t = New-Object System.Text.StringBuilder 512
|
|
131
|
+
[void][MmapWin]::GetWindowText($h, $t, 512)
|
|
132
|
+
[void]$wins.Add(@{ hwnd = $h.ToInt64(); title = $t.ToString() })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return $true
|
|
136
|
+
}
|
|
137
|
+
[void][MmapWin]::EnumWindows($cb, [IntPtr]::Zero)
|
|
138
|
+
return ,$wins
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
$ancestors = @()
|
|
142
|
+
$p = $PID
|
|
143
|
+
for ($i = 0; $i -lt 12 -and $p; $i++) {
|
|
144
|
+
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$p"
|
|
145
|
+
if (-not $proc) { break }
|
|
146
|
+
if ($i -gt 0) { $ancestors += [uint32]$proc.ProcessId }
|
|
147
|
+
$p = $proc.ParentProcessId
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# The agent CLI (claude/codex/...) is some ancestor holding the console that a
|
|
151
|
+
# WT window renders; hidden-console ancestors just never light a window up.
|
|
152
|
+
$nonce = "__NONCE__"
|
|
153
|
+
$hwnd = [IntPtr]::Zero
|
|
154
|
+
foreach ($apid in $ancestors) {
|
|
155
|
+
[void][MmapWin]::FreeConsole()
|
|
156
|
+
if (-not [MmapWin]::AttachConsole($apid)) { continue }
|
|
157
|
+
$sb = New-Object System.Text.StringBuilder 1024
|
|
158
|
+
[void][MmapWin]::GetConsoleTitle($sb, 1024)
|
|
159
|
+
$orig = $sb.ToString()
|
|
160
|
+
for ($i = 0; $i -lt 6 -and $hwnd -eq [IntPtr]::Zero; $i++) {
|
|
161
|
+
[void][MmapWin]::SetConsoleTitle($nonce)
|
|
162
|
+
Start-Sleep -Milliseconds 60
|
|
163
|
+
foreach ($w in (Get-WtWindows)) {
|
|
164
|
+
if ($w.title -like "*$nonce*") { $hwnd = [IntPtr]$w.hwnd; break }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
Start-Sleep -Milliseconds 100
|
|
168
|
+
[void][MmapWin]::SetConsoleTitle($orig)
|
|
169
|
+
Start-Sleep -Milliseconds 200
|
|
170
|
+
[void][MmapWin]::FreeConsole()
|
|
171
|
+
if ($hwnd -ne [IntPtr]::Zero) { break }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if ($hwnd -eq [IntPtr]::Zero) { Write-Output 'IDENT=0'; exit 0 }
|
|
175
|
+
Write-Output "IDENT=$($hwnd.ToInt64())"
|
|
176
|
+
|
|
177
|
+
if ([MmapWin]::IsIconic($hwnd)) { [void][MmapWin]::ShowWindow($hwnd, 9) }
|
|
178
|
+
$focused = $false
|
|
179
|
+
[void][MmapWin]::SetForegroundWindow($hwnd)
|
|
180
|
+
Start-Sleep -Milliseconds 150
|
|
181
|
+
if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
|
|
182
|
+
if (-not $focused) {
|
|
183
|
+
[MmapWin]::keybd_event(0x12, 0, 0, [IntPtr]::Zero)
|
|
184
|
+
[void][MmapWin]::SetForegroundWindow($hwnd)
|
|
185
|
+
[MmapWin]::keybd_event(0x12, 0, 2, [IntPtr]::Zero)
|
|
186
|
+
Start-Sleep -Milliseconds 150
|
|
187
|
+
if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
|
|
188
|
+
}
|
|
189
|
+
if (-not $focused) {
|
|
190
|
+
$fgpid = 0
|
|
191
|
+
$fgThread = [MmapWin]::GetWindowThreadProcessId([MmapWin]::GetForegroundWindow(), [ref]$fgpid)
|
|
192
|
+
$myThread = [MmapWin]::GetCurrentThreadId()
|
|
193
|
+
[void][MmapWin]::AttachThreadInput($myThread, $fgThread, $true)
|
|
194
|
+
[void][MmapWin]::BringWindowToTop($hwnd)
|
|
195
|
+
[void][MmapWin]::SetForegroundWindow($hwnd)
|
|
196
|
+
[void][MmapWin]::AttachThreadInput($myThread, $fgThread, $false)
|
|
197
|
+
Start-Sleep -Milliseconds 150
|
|
198
|
+
if ([MmapWin]::GetForegroundWindow() -eq $hwnd) { $focused = $true }
|
|
199
|
+
}
|
|
200
|
+
Write-Output "FOCUS=$(if ($focused) { 1 } else { 0 })"
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
function paneCommand() {
|
|
204
|
+
const cmd = ['--title', 'mellos map', '-d', projectDir, 'node', watchPath, '--file', mapFile];
|
|
205
|
+
if (flags.has('--ascii')) cmd.push('--ascii');
|
|
206
|
+
return cmd;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function openWt(args, what) {
|
|
210
|
+
const r = spawnSync('wt', args, { stdio: 'ignore', timeout: 15_000, windowsHide: true });
|
|
211
|
+
if (r.status !== 0) {
|
|
212
|
+
console.error(`wt failed to ${what} (exit ${r.status ?? 'timeout'}) — is Windows Terminal installed?`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function openDedicatedWindow(reason) {
|
|
218
|
+
openWt(['-w', 'mellos-mapping', 'nt', ...paneCommand()], 'open the dedicated window');
|
|
219
|
+
console.log(`MMAP_PANE mode=window name=mellos-mapping reason=${reason}`);
|
|
220
|
+
console.log('Map opened in the dedicated "mellos-mapping" window.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!flags.has('--force') && watcherAlreadyRunning()) {
|
|
224
|
+
console.log('MMAP_PANE already-open');
|
|
225
|
+
console.log(`A watcher for ${mapFile} is already running — not opening another pane (use --force to override).`);
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (flags.has('--window')) {
|
|
230
|
+
openDedicatedWindow('requested');
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const nonce = `MMAP-NONCE-${process.pid}`;
|
|
235
|
+
const out = runPowerShell(IDENTIFY_AND_FOCUS.replaceAll('__NONCE__', nonce));
|
|
236
|
+
const ident = out.match(/IDENT=(\d+)/)?.[1] ?? '0';
|
|
237
|
+
const focused = /FOCUS=1/.test(out);
|
|
238
|
+
|
|
239
|
+
if (ident === '0') {
|
|
240
|
+
openDedicatedWindow('session-window-not-identified');
|
|
241
|
+
} else if (!focused) {
|
|
242
|
+
openDedicatedWindow('session-window-focus-denied');
|
|
243
|
+
} else {
|
|
244
|
+
// The identified window is foreground right now, so "most recently used"
|
|
245
|
+
// is deterministically it (see the race note in the header).
|
|
246
|
+
openWt(['-w', '0', 'sp', '-V', '--size', '0.42', ...paneCommand()], 'split the session window');
|
|
247
|
+
console.log(`MMAP_PANE mode=split hwnd=${ident}`);
|
|
248
|
+
console.log('Map opened beside this conversation (vertical split).');
|
|
249
|
+
}
|