mellos-mapping 0.20.2 → 0.22.1
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 +125 -88
- package/README.zh-CN.md +122 -84
- package/dist/hook-session-start.mjs +25 -19
- package/dist/mmap.mjs +302 -170
- package/dist/preview.mjs +1418 -0
- package/dist/server.mjs +1198 -400
- package/dist/store-paths.mjs +40 -21
- package/dist/terminal-worker.mjs +3188 -0
- package/dist/watch.mjs +523 -280
- package/dist/web/TERMINAL-LICENSES.txt +70 -0
- package/dist/web/app.css +967 -0
- package/dist/web/app.js +1356 -0
- package/dist/web/index.html +9 -0
- package/dist/web/terminal.css +9 -0
- package/dist/web/terminal.html +7 -0
- package/dist/web/terminal.js +9293 -0
- package/dist/web/xterm.css +285 -0
- package/dist/web.mjs +5535 -0
- package/docs/codex.md +183 -0
- package/lib/domain/text.d.ts +9 -0
- package/lib/domain/text.js +43 -0
- package/lib/domain/types.js +10 -1
- package/lib/preview/index.d.ts +3 -0
- package/lib/preview/index.js +3 -0
- package/lib/preview/markdown.d.ts +8 -0
- package/lib/preview/markdown.js +54 -0
- package/lib/preview/presentation.d.ts +6 -0
- package/lib/preview/presentation.js +14 -0
- package/lib/preview/publisher.d.ts +23 -0
- package/lib/preview/publisher.js +143 -0
- package/lib/preview/svg.d.ts +3 -0
- package/lib/preview/svg.js +74 -0
- package/lib/preview/text.d.ts +4 -0
- package/lib/preview/text.js +13 -0
- package/lib/render/canvas.d.ts +1 -1
- package/lib/render/canvas.js +4 -2
- package/lib/render/draw.js +9 -6
- package/lib/render/render.d.ts +6 -0
- package/lib/render/render.js +50 -18
- package/lib/render/width.js +3 -1
- package/lib/store/atomic.d.ts +18 -0
- package/lib/store/atomic.js +86 -0
- package/lib/store/channels.d.ts +40 -0
- package/lib/store/channels.js +135 -0
- package/lib/store/format.js +3 -1
- package/lib/store/json-text.d.ts +9 -0
- package/lib/store/json-text.js +16 -0
- package/lib/store/maps.d.ts +12 -0
- package/lib/store/maps.js +42 -0
- package/lib/store/migration.d.ts +12 -0
- package/lib/store/migration.js +46 -0
- package/lib/store/pages.d.ts +46 -0
- package/lib/store/pages.js +89 -0
- package/lib/store/policy.d.ts +74 -0
- package/lib/store/policy.js +144 -0
- package/lib/store/store.d.ts +9 -256
- package/lib/store/store.js +10 -694
- package/lib/store/viewers.d.ts +81 -0
- package/lib/store/viewers.js +186 -0
- package/package.json +25 -6
- package/scripts/codex-cli.mjs +42 -0
- package/scripts/codex-register.mjs +27 -94
- package/scripts/mmap.mjs +26 -16
- package/scripts/open-pane.mjs +37 -18
- package/scripts/pane-core.mjs +57 -220
- package/scripts/terminal-session.mjs +137 -0
- package/scripts/tmux-session.mjs +90 -0
- package/scripts/watcher-command.mjs +16 -0
package/scripts/open-pane.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Open the live map pane in the
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
133
|
-
|
|
142
|
+
fail(placed.error);
|
|
143
|
+
}
|
|
144
|
+
const reported = await awaitNewPane(store, mapFile, context);
|
|
145
|
+
if (!reported.ok) {
|
|
146
|
+
fail(reported.error);
|
|
134
147
|
}
|
|
135
|
-
|
|
136
|
-
|
|
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
|
}
|
package/scripts/pane-core.mjs
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
*
|
|
30
30
|
* Pure helpers are exported for the spec; nothing here runs on import.
|
|
31
31
|
*/
|
|
32
|
-
import
|
|
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
|
-
|
|
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
|
-
/**
|
|
194
|
-
export
|
|
195
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** tmux adapter: discover an attached session and address it without shell eval. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { watcherArgs } from './watcher-command.mjs';
|
|
5
|
+
|
|
6
|
+
const SESSION_FORMAT = '#{session_id}\t#{session_attached}';
|
|
7
|
+
const TARGET_FORMAT = '#{socket_path}\t#{session_id}\t#{session_created}\t#{session_attached}\t#{pane_id}\t#{pane_pid}';
|
|
8
|
+
const PANE_FORMAT = '#{pane_id}\t#{pane_pid}\t#{window_id}\t#{window_active}\t#{window_zoomed_flag}\t#{pane_active}';
|
|
9
|
+
|
|
10
|
+
export function createTmuxAdapter({ env = process.env, spawn = spawnSync, nodePath = process.execPath } = {}) {
|
|
11
|
+
// Socket paths may contain commas. Only the final two fields are numeric.
|
|
12
|
+
const inherited = /^(.*),\d+,(\d+)$/.exec(env.TMUX ?? '');
|
|
13
|
+
const socket = env.MELLOS_MAPPING_TMUX_SOCKET || inherited?.[1];
|
|
14
|
+
|
|
15
|
+
function run(args, socketPath = socket) {
|
|
16
|
+
const result = spawn('tmux', [...(socketPath ? ['-S', socketPath] : []), ...args], {
|
|
17
|
+
env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5_000, windowsHide: true,
|
|
18
|
+
});
|
|
19
|
+
return result.status === 0
|
|
20
|
+
? { ok: true, value: result.stdout.trim() }
|
|
21
|
+
: { ok: false, error: `tmux ${args[0]} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function inspectSession(cfg, viewers = []) {
|
|
25
|
+
// An explicitly selected socket must not inherit a pane id from another server.
|
|
26
|
+
const sameServer = !env.MELLOS_MAPPING_TMUX_SOCKET || socket === inherited?.[1];
|
|
27
|
+
let selected = env.MELLOS_MAPPING_TMUX_TARGET || (sameServer && inherited
|
|
28
|
+
? (/^%\d+$/.test(env.TMUX_PANE ?? '') ? env.TMUX_PANE : `$${inherited[2]}:`)
|
|
29
|
+
: undefined);
|
|
30
|
+
if (!selected) {
|
|
31
|
+
const listed = run(['list-sessions', '-F', SESSION_FORMAT]);
|
|
32
|
+
if (!listed.ok) return listed;
|
|
33
|
+
const attached = listed.value.split('\n').map(line => line.split('\t'))
|
|
34
|
+
.filter(([id, count]) => /^\$\d+$/.test(id) && Number(count) > 0).map(([id]) => id);
|
|
35
|
+
if (attached.length !== 1) return { ok: false, error: attached.length === 0
|
|
36
|
+
? 'No attached tmux session was found. Attach a session before retrying.'
|
|
37
|
+
: `Multiple attached tmux sessions (${attached.join(', ')}). Set MELLOS_MAPPING_TMUX_TARGET to the intended session or pane in the MCP server environment before retrying.` };
|
|
38
|
+
selected = `${attached[0]}:`;
|
|
39
|
+
}
|
|
40
|
+
const inspected = run(['display-message', '-p', '-t', selected, TARGET_FORMAT]);
|
|
41
|
+
if (!inspected.ok) return inspected;
|
|
42
|
+
const [socketPath, session, created, attached, pane, panePid] = inspected.value.split('\t');
|
|
43
|
+
if (!socketPath || !/^\$\d+$/.test(session) || !/^\d+$/.test(created) || !/^%\d+$/.test(pane)) {
|
|
44
|
+
return { ok: false, error: 'tmux returned an invalid session target; no pane was opened.' };
|
|
45
|
+
}
|
|
46
|
+
if (!(Number(attached) > 0)) return { ok: false, error: `tmux session ${session} has no attached client. Attach it before retrying.` };
|
|
47
|
+
// Window ownership survives selecting the newly created window; splits
|
|
48
|
+
// belong to the source pane, so conversations in one session stay separate.
|
|
49
|
+
const identity = [socketPath, session, created, cfg.mode, cfg.mode === 'window' ? '' : pane];
|
|
50
|
+
// When a stripped environment resolves to the map itself, its live process
|
|
51
|
+
// report carries the original conversation identity. Do not invent a new owner.
|
|
52
|
+
const selectedViewer = cfg.mode === 'split' ? viewers.find(viewer =>
|
|
53
|
+
viewer.pid === Number(panePid) && /^tmux-[a-f0-9]{24}$/.test(viewer.owner ?? '')) : undefined;
|
|
54
|
+
const owner = selectedViewer?.owner ?? `tmux-${createHash('sha256').update(JSON.stringify(identity)).digest('hex').slice(0, 24)}`;
|
|
55
|
+
return { ok: true, value: { backend: 'tmux', socket: socketPath, session, pane, owner, owners: [owner] } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function revealPane(target, viewer) {
|
|
59
|
+
const listed = run(['list-panes', '-s', '-t', target.session, '-F', PANE_FORMAT], target.socket);
|
|
60
|
+
if (!listed.ok) return listed;
|
|
61
|
+
const pane = listed.value.split('\n').map(line => line.split('\t')).find(fields => Number(fields[1]) === viewer.pid);
|
|
62
|
+
if (!pane || !/^%\d+$/.test(pane[0]) || !/^@\d+$/.test(pane[2])) {
|
|
63
|
+
return { ok: false, error: 'The live watcher could not be located in this tmux session; visibility is unverified.' };
|
|
64
|
+
}
|
|
65
|
+
const selected = run(['select-window', '-t', `${target.session}:${pane[2]}`], target.socket);
|
|
66
|
+
if (!selected.ok) return selected;
|
|
67
|
+
if (pane[4] === '1' && pane[5] !== '1') {
|
|
68
|
+
const unzoomed = run(['resize-pane', '-Z', '-t', pane[0]], target.socket);
|
|
69
|
+
if (!unzoomed.ok) return unzoomed;
|
|
70
|
+
}
|
|
71
|
+
const visible = run(['display-message', '-p', '-t', `${target.session}:${pane[2]}.${pane[0]}`,
|
|
72
|
+
'#{session_attached}\t#{window_active}\t#{window_zoomed_flag}\t#{pane_active}'], target.socket);
|
|
73
|
+
if (!visible.ok) return visible;
|
|
74
|
+
const [attached, active, zoomed, focused] = visible.value.split('\t');
|
|
75
|
+
return Number(attached) > 0 && active === '1' && (zoomed !== '1' || focused === '1')
|
|
76
|
+
? { ok: true, value: 'visible' }
|
|
77
|
+
: { ok: false, error: 'The watcher is running, but its tmux pane is not visible in the attached session.' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openPane(cfg, watchPath, mapFile, target) {
|
|
81
|
+
const placement = target.mode === 'window'
|
|
82
|
+
? ['new-window', '-n', 'mellos-mapping', '-t', `${target.session}:`]
|
|
83
|
+
: ['split-window', '-h', '-d', '-l', '42%', '-t', target.pane];
|
|
84
|
+
const result = run([...placement, '-c', cfg.projectDir,
|
|
85
|
+
nodePath, ...watcherArgs(cfg, watchPath, mapFile), '--owner', target.owner], target.socket);
|
|
86
|
+
return result.ok ? { ok: true, value: { ...target, reason: 'requested' } } : result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { kind: 'tmux', inspectSession, openPane, revealPane };
|
|
90
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Watcher arguments shared by terminal adapters and the copyable fallback. */
|
|
2
|
+
export function watcherArgs(cfg, watchPath, mapFile) {
|
|
3
|
+
const args = [watchPath, '--file', mapFile, ...cfg.watcherFlags];
|
|
4
|
+
if (cfg.pageSlug !== undefined) args.push('--page', cfg.pageSlug);
|
|
5
|
+
return args;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** POSIX shell quoting is only for the human fallback; adapters use argv. */
|
|
9
|
+
export function manualWatcherCommand(cfg, watchPath, mapFile, nodePath = process.execPath) {
|
|
10
|
+
return [nodePath, ...watcherArgs(cfg, watchPath, mapFile)]
|
|
11
|
+
.map(arg => "'" + arg.replaceAll("'", "'\"'\"'") + "'").join(' ');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function paneFailureMessage(error, cfg, watchPath, mapFile, platform = process.platform) {
|
|
15
|
+
return platform === 'win32' ? error : `${error}\nAutomatic opening failed. Do not retry until the terminal environment changes. Run this command in a visible terminal:\n${manualWatcherCommand(cfg, watchPath, mapFile)}`;
|
|
16
|
+
}
|