mellos-mapping 0.27.1 → 0.28.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 CHANGED
@@ -337,8 +337,8 @@ get the eight `mmap_*` tools and the pane, and bring their own prompting.
337
337
  opens or retargets the pane with `mmap_open` when nobody is — you never
338
338
  have to remember to. Open or close it yourself with `mmap` in any
339
339
  terminal, or `/mellos-mapping:mmap` in the conversation (Windows
340
- Terminal split on Windows, tmux split inside tmux, or a printed command
341
- to run in any second terminal). On Windows the pane opens in the terminal
340
+ Terminal split on Windows, tmux split inside tmux, a Pebrel split inside
341
+ Pebrel, or a printed command to run in any second terminal). On Windows the pane opens in the terminal
342
342
  window hosting YOUR session, even with several windows open; pass
343
343
  `--window` to put the map in its own dedicated window instead. Prefer
344
344
  `--ascii` if your font lacks box-drawing glyphs.
@@ -359,6 +359,13 @@ the environment of the MCP server (restart it after changing them):
359
359
  | `MELLOS_MAPPING_TMUX_TARGET` | Explicit tmux session or pane, such as `work:2.1` or `%7` |
360
360
  | `MELLOS_MAPPING_TMUX_SOCKET` | Absolute socket path, for example `/tmp/my-tmux/socket` |
361
361
 
362
+ Inside [Pebrel](https://github.com/Kuddev/pebrel), `mmap_open` and `mmap` split the pane that hosts
363
+ the conversation through Pebrel's runtime CLI (`PEBREL_CLI`). The launcher
364
+ confirms which window that pane is in from its process tree, returns input focus
365
+ to the conversation and types the watcher into the new pane's shell (PowerShell,
366
+ cmd or a POSIX shell), which exits with it so the pane closes with the map.
367
+ `--window` opens a new Pebrel window. Inside tmux, tmux owns the split.
368
+
362
369
  Ambiguous sessions, detached sessions and missing tmux produce a concrete error
363
370
  and a fully quoted watcher command, including the project and requested page,
364
371
  to paste into any visible terminal. Map writes remain available; after an open
package/README.zh-CN.md CHANGED
@@ -310,8 +310,8 @@ npx -y -p mellos-mapping mellos-mapping-watch
310
310
  (见[谁在看](#谁在看));没人看的时候,Claude 用 `mmap_open` 自己
311
311
  把面板开出来或切到该看的那一页——你不需要记着去开它。想自己开关,
312
312
  在任意终端敲 `mmap`,或者在对话里用 `/mellos-mapping:mmap`
313
- (Windows 上是 Windows Terminal 分屏,tmux 里是 tmux 分屏,其他
314
- 环境会打印一条命令让你在第二个终端里运行)。Windows 上即使开着多个
313
+ (Windows 上是 Windows Terminal 分屏,tmux 里是 tmux 分屏,Pebrel 里
314
+ 是 Pebrel 分屏,其他环境会打印一条命令让你在第二个终端里运行)。Windows 上即使开着多个
315
315
  终端窗口,分屏也会落在**你的会话所在的窗口**;想让地图独占一个窗口
316
316
  就加 `--window`。字体缺少制表符字形时用 `--ascii`。
317
317
  3. 看着节点从底部一路亮起。图让你不安的时候就打断它——这正是它存在的
@@ -330,6 +330,12 @@ tmux 服务中唯一已连接的会话。默认在右侧分屏并保留输入焦
330
330
  | `MELLOS_MAPPING_TMUX_TARGET` | 指定 tmux 会话或面板,例如 `work:2.1` 或 `%7` |
331
331
  | `MELLOS_MAPPING_TMUX_SOCKET` | socket 的绝对路径,例如 `/tmp/my-tmux/socket` |
332
332
 
333
+ 在 [Pebrel](https://github.com/Kuddev/pebrel) 里,`mmap_open` 与 `mmap` 通过 Pebrel 运行时 CLI
334
+ (`PEBREL_CLI`)分屏承载本会话的面板。启动器用进程树确认该面板所在的窗口,
335
+ 把输入焦点还给对话,再把 watcher 命令输入新面板的 shell(PowerShell、cmd
336
+ 或 POSIX shell);shell 随 watcher 一起退出,地图关闭时面板也随之关闭。
337
+ `--window` 会新开一个 Pebrel 窗口。在 tmux 里则由 tmux 负责分屏。
338
+
333
339
  目标不明确、会话未连接或没有安装 tmux 时,启动器会返回具体原因,以及
334
340
  包含项目、页面和正确引号的一条完整 watcher 命令,可以粘贴到可见终端。
335
341
  地图仍可正常写入;打开失败后,助手只在环境变化或你要求重试时再次打开。
package/commands/mmap.md CHANGED
@@ -104,7 +104,12 @@ Follow the platform-appropriate route:
104
104
  also set `MELLOS_MAPPING_TMUX_SOCKET` to its absolute path, then restart the
105
105
  MCP server. Do not guess a session or open a detached window.
106
106
 
107
- 3. **Neither**: print the watcher command and tell the user to run it in any
107
+ 3. **Pebrel** (`PEBREL_CLI` set and not inside tmux): use the same `mmap_open`
108
+ tool or launcher command as route 1. It splits the Pebrel pane hosting this
109
+ conversation, returns input focus to it and types the watcher into the new
110
+ pane's shell; `--window` opens a new Pebrel window.
111
+
112
+ 4. **None of these**: print the watcher command and tell the user to run it in any
108
113
  second terminal themselves (add `--ascii` if their font lacks box-drawing
109
114
  characters). The launcher's own failure message quotes it in full, with
110
115
  absolute paths — relay that one verbatim. Written by hand it is
package/dist/mmap.mjs CHANGED
@@ -15,8 +15,11 @@ function watcherArgs(cfg, watchPath, mapFile) {
15
15
  if (cfg.pageSlug !== void 0) args.push("--page", cfg.pageSlug);
16
16
  return args;
17
17
  }
18
+ function posixQuote(arg) {
19
+ return "'" + arg.replaceAll("'", `'"'"'`) + "'";
20
+ }
18
21
  function manualWatcherCommand(cfg, watchPath, mapFile, nodePath = process.execPath) {
19
- return [nodePath, ...watcherArgs(cfg, watchPath, mapFile)].map((arg) => "'" + arg.replaceAll("'", `'"'"'`) + "'").join(" ");
22
+ return [nodePath, ...watcherArgs(cfg, watchPath, mapFile)].map(posixQuote).join(" ");
20
23
  }
21
24
  function paneFailureMessage(error, cfg, watchPath, mapFile, platform = process.platform) {
22
25
  return platform === "win32" ? error : `${error}
@@ -29,11 +32,13 @@ var terminal_session_exports = {};
29
32
  __export(terminal_session_exports, {
30
33
  focusSession: () => focusSession,
31
34
  inspectSession: () => inspectSession,
35
+ kind: () => kind,
32
36
  openWt: () => openWt,
33
37
  parseSessionProbe: () => parseSessionProbe,
34
38
  sessionProbeScript: () => sessionProbeScript
35
39
  });
36
40
  import { spawnSync } from "node:child_process";
41
+ var kind = "wt";
37
42
  var NATIVE = String.raw`
38
43
  $ErrorActionPreference = 'Stop'
39
44
  $ProgressPreference = 'SilentlyContinue'
@@ -158,13 +163,144 @@ function openWt(args, what) {
158
163
  return result.status === 0 ? { ok: true, value: void 0 } : { ok: false, error: `wt failed to ${what} (${result.error?.message ?? `exit ${result.status}`}).` };
159
164
  }
160
165
 
161
- // scripts/tmux-session.mjs
166
+ // scripts/pebrel-session.mjs
162
167
  import { spawnSync as spawnSync2 } from "node:child_process";
163
168
  import { createHash } from "node:crypto";
169
+ var CTL_TIMEOUT_MS = 1e4;
170
+ var SPLIT_RATIO = "0.42";
171
+ var TERMINAL_TAB_KINDS = ["shell", "profile"];
172
+ function hostedByPebrel(env) {
173
+ return Boolean(env.PEBREL_CLI && env.PEBREL_PANE_ID) && !env.TMUX;
174
+ }
175
+ function shellCommandLine(executable, argv) {
176
+ const shell = executable.toLowerCase().replace(/\.exe$/, "");
177
+ switch (shell) {
178
+ case "pwsh":
179
+ case "powershell":
180
+ return { ok: true, value: `& ${argv.map(powershellQuote).join(" ")}; exit` };
181
+ case "cmd":
182
+ return argv.some((arg) => /["%]/.test(arg)) ? { ok: false, error: 'cmd cannot quote a path containing " or %; no pane was kept.' } : { ok: true, value: `${argv.map((arg) => `"${arg}"`).join(" ")} & exit` };
183
+ case "sh":
184
+ case "bash":
185
+ case "zsh":
186
+ case "dash":
187
+ case "ksh":
188
+ return { ok: true, value: `exec ${argv.map(posixQuote).join(" ")}` };
189
+ default:
190
+ return { ok: false, error: `The new Pebrel pane runs ${executable}; the map starts only from PowerShell, cmd or a POSIX shell. No pane was kept.` };
191
+ }
192
+ }
193
+ function powershellQuote(arg) {
194
+ return `'${arg.replace(/['‘’‚‛]/g, "$&$&")}'`;
195
+ }
196
+ function createPebrelAdapter({ env = process.env, spawn = spawnSync2, nodePath = process.execPath, pid = process.pid } = {}) {
197
+ function ctl(command, ...args) {
198
+ const result = spawn(env.PEBREL_CLI, ["ctl", command, ...args.map(String)], {
199
+ encoding: "utf8",
200
+ stdio: ["ignore", "pipe", "pipe"],
201
+ timeout: CTL_TIMEOUT_MS,
202
+ windowsHide: true
203
+ });
204
+ const reply = parseReply(result.stdout);
205
+ return reply?.ok === true ? { ok: true, value: reply.result } : { ok: false, error: `pebrel ctl ${command} failed: ${reply?.error?.message ?? result.error?.message ?? (result.stderr?.trim() || `exit ${result.status}`)}` };
206
+ }
207
+ function hosts(window, pane, processId) {
208
+ const tree = ctl("procs", "--window", window, "--pane", pane);
209
+ return tree.ok ? { ok: true, value: tree.value.processes.some((item) => item.pid === processId) } : tree;
210
+ }
211
+ function inspectSession2(cfg) {
212
+ const snapshot = ctl("snapshot");
213
+ if (!snapshot.ok) return snapshot;
214
+ const pane = Number(env.PEBREL_PANE_ID);
215
+ for (const window of snapshot.value.windows) {
216
+ if (!window.tabs.some((tab) => tab.panes.some((item) => item.id === pane))) continue;
217
+ const ours = hosts(window.id, pane, pid);
218
+ if (!ours.ok) return ours;
219
+ if (!ours.value) continue;
220
+ const identity = [snapshot.value.process_id, cfg.mode, cfg.mode === "window" ? "" : `${window.id}:${pane}`];
221
+ const owner = `pebrel-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
222
+ return { ok: true, value: { backend: "pebrel", window: window.id, pane, owner, owners: [owner] } };
223
+ }
224
+ return { ok: false, error: `This process is not running inside Pebrel pane ${env.PEBREL_PANE_ID}; no pane was opened.` };
225
+ }
226
+ function createPane(target) {
227
+ if (target.mode === "window") return ctl("new-window");
228
+ const split = ctl("split", "--window", target.window, "--pane", target.pane, "--direction", "right");
229
+ if (!split.ok) return split;
230
+ const created = split.value.action;
231
+ const focused = ctl("focus", "--window", target.window, "--pane", target.pane);
232
+ const resized = focused.ok ? ctl("resize-pane", "--window", created.window_id, "--pane", created.pane_id, "--ratio", SPLIT_RATIO) : focused;
233
+ if (resized.ok) return split;
234
+ ctl("close-pane", "--window", created.window_id, "--pane", created.pane_id);
235
+ return resized;
236
+ }
237
+ function startWatcher(cfg, watchPath, mapFile, target, created) {
238
+ const tree = ctl("procs", "--window", created.window_id, "--pane", created.pane_id);
239
+ if (!tree.ok) return tree;
240
+ const shell = tree.value.processes.find((item) => item.depth === 0)?.executable ?? "no shell";
241
+ const line = shellCommandLine(shell, [nodePath, ...watcherArgs(cfg, watchPath, mapFile), "--owner", target.owner]);
242
+ return line.ok ? ctl("prompt", "--window", created.window_id, "--pane", created.pane_id, "--text", line.value) : line;
243
+ }
244
+ function openPane(cfg, watchPath, mapFile, target) {
245
+ const created = createPane(target);
246
+ if (!created.ok) return created;
247
+ const pane = created.value.action;
248
+ const started = startWatcher(cfg, watchPath, mapFile, target, pane);
249
+ if (started.ok) return { ok: true, value: { ...target, opened: { window: pane.window_id, pane: pane.pane_id } } };
250
+ ctl("close-pane", "--window", pane.window_id, "--pane", pane.pane_id);
251
+ return started;
252
+ }
253
+ function locate(processId) {
254
+ const snapshot = ctl("snapshot");
255
+ if (!snapshot.ok) return snapshot;
256
+ for (const window of snapshot.value.windows) {
257
+ for (const tab of window.tabs.filter((item) => TERMINAL_TAB_KINDS.includes(item.kind))) {
258
+ for (const pane of tab.panes.filter((item) => item.ssh_destination === null)) {
259
+ const found = hosts(window.id, pane.id, processId);
260
+ if (!found.ok) return found;
261
+ if (found.value) return { ok: true, value: { window, tab, pane } };
262
+ }
263
+ }
264
+ }
265
+ return { ok: false, error: "The live watcher could not be located in any Pebrel pane; visibility is unverified." };
266
+ }
267
+ function revealPane(target, viewer) {
268
+ const located = locate(viewer.pid);
269
+ if (!located.ok) return located;
270
+ const { window, tab, pane } = located.value;
271
+ if (zoomedOver(tab, pane.id)) {
272
+ const unzoomed = ctl("zoom-pane", "--window", window.id, "--pane", tab.zoomed_pane_id, "--zoomed", "false");
273
+ if (!unzoomed.ok) return unzoomed;
274
+ }
275
+ if (!tab.active) {
276
+ const focused = ctl("focus", "--window", window.id, "--pane", target.mode === "window" ? pane.id : target.pane);
277
+ if (!focused.ok) return focused;
278
+ }
279
+ const after = ctl("snapshot");
280
+ if (!after.ok) return after;
281
+ const shown = after.value.windows.find((item) => item.id === window.id)?.tabs.find((item) => item.panes.some((member) => member.id === pane.id));
282
+ return shown?.active && !zoomedOver(shown, pane.id) ? { ok: true, value: "visible" } : { ok: false, error: "The watcher is running, but its Pebrel pane is not visible in its window." };
283
+ }
284
+ return { kind: "pebrel", inspectSession: inspectSession2, openPane, revealPane };
285
+ }
286
+ function zoomedOver(tab, paneId) {
287
+ return tab.zoomed_pane_id !== void 0 && tab.zoomed_pane_id !== null && tab.zoomed_pane_id !== paneId;
288
+ }
289
+ function parseReply(stdout) {
290
+ try {
291
+ return JSON.parse(stdout);
292
+ } catch {
293
+ return void 0;
294
+ }
295
+ }
296
+
297
+ // scripts/tmux-session.mjs
298
+ import { spawnSync as spawnSync3 } from "node:child_process";
299
+ import { createHash as createHash2 } from "node:crypto";
164
300
  var SESSION_FORMAT = "#{session_id} #{session_attached}";
165
301
  var TARGET_FORMAT = "#{socket_path} #{session_id} #{session_created} #{session_attached} #{pane_id} #{pane_pid}";
166
302
  var PANE_FORMAT = "#{pane_id} #{pane_pid} #{window_id} #{window_active} #{window_zoomed_flag} #{pane_active}";
167
- function createTmuxAdapter({ env = process.env, spawn = spawnSync2, nodePath = process.execPath } = {}) {
303
+ function createTmuxAdapter({ env = process.env, spawn = spawnSync3, nodePath = process.execPath } = {}) {
168
304
  const inherited = /^(.*),\d+,(\d+)$/.exec(env.TMUX ?? "");
169
305
  const socket = env.MELLOS_MAPPING_TMUX_SOCKET || inherited?.[1];
170
306
  function run(args, socketPath = socket) {
@@ -196,7 +332,7 @@ function createTmuxAdapter({ env = process.env, spawn = spawnSync2, nodePath = p
196
332
  if (!(Number(attached) > 0)) return { ok: false, error: `tmux session ${session} has no attached client. Attach it before retrying.` };
197
333
  const identity = [socketPath, session, created, cfg.mode, cfg.mode === "window" ? "" : pane];
198
334
  const selectedViewer = cfg.mode === "split" ? viewers.find((viewer) => viewer.pid === Number(panePid) && /^tmux-[a-f0-9]{24}$/.test(viewer.owner ?? "")) : void 0;
199
- const owner = selectedViewer?.owner ?? `tmux-${createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
335
+ const owner = selectedViewer?.owner ?? `tmux-${createHash2("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24)}`;
200
336
  return { ok: true, value: { backend: "tmux", socket: socketPath, session, pane, owner, owners: [owner] } };
201
337
  }
202
338
  function revealPane(target, viewer) {
@@ -298,14 +434,14 @@ var SPLIT_SIZE = "0.42";
298
434
  function selectPaneViewer(viewers, owners) {
299
435
  return viewers.find((viewer) => viewer.owner !== void 0 && owners.includes(viewer.owner));
300
436
  }
301
- var platformTerminal = () => process.platform === "win32" ? terminal_session_exports : createTmuxAdapter();
437
+ var platformTerminal = () => hostedByPebrel(process.env) ? createPebrelAdapter() : process.platform === "win32" ? terminal_session_exports : createTmuxAdapter();
302
438
  function preparePane(cfg, store, mapFile, io = platformTerminal()) {
303
439
  const viewers = store.readLiveViewers(mapFile, Date.now());
304
- const inspected = io.kind === "tmux" ? io.inspectSession(cfg, viewers) : cfg.mode === PANE_MODE.window ? { ok: true, value: { owners: ["window"], owner: "window" } } : io.inspectSession();
440
+ const inspected = io.kind !== "wt" ? io.inspectSession(cfg, viewers) : cfg.mode === PANE_MODE.window ? { ok: true, value: { owners: ["window"], owner: "window" } } : io.inspectSession();
305
441
  if (!inspected.ok) return inspected;
306
442
  const session = inspected.value;
307
443
  const viewer = selectPaneViewer(viewers, session.owners);
308
- if (io.kind !== "tmux" && cfg.mode === PANE_MODE.split && !session.hwnd && (!viewer || cfg.force)) {
444
+ if (io.kind === "wt" && cfg.mode === PANE_MODE.split && !session.hwnd && (!viewer || cfg.force)) {
309
445
  return { ok: false, error: "Could not identify this conversation\u2019s active Windows Terminal pane. Activate its PowerShell tab and retry; no separate window was opened. Use --window only if you want a separate window." };
310
446
  }
311
447
  return { ok: true, value: {
@@ -315,7 +451,7 @@ function preparePane(cfg, store, mapFile, io = platformTerminal()) {
315
451
  } };
316
452
  }
317
453
  function placePane(cfg, watchPath, mapFile, target, io = platformTerminal()) {
318
- if (io.kind === "tmux") return io.openPane(cfg, watchPath, mapFile, target);
454
+ if (io.kind !== "wt") return io.openPane(cfg, watchPath, mapFile, target);
319
455
  const payload = [...paneCommand(cfg, watchPath, mapFile), "--owner", target.owner];
320
456
  if (target.mode === PANE_MODE.window) {
321
457
  const opened2 = io.openWt(["-w", DEDICATED_WINDOW_NAME, "nt", ...payload], "open the requested window");
@@ -451,7 +587,7 @@ async function main() {
451
587
  if (!reported.ok) {
452
588
  fail(reported.error);
453
589
  }
454
- const where = placed.value.backend === "tmux" ? placed.value.mode === PANE_MODE.window ? "in a new tmux window" : "beside this terminal (tmux split)" : placed.value.mode === PANE_MODE.window ? `in the dedicated "${DEDICATED_WINDOW_NAME}" window (${placed.value.reason})` : "beside this terminal (vertical split)";
590
+ const where = placed.value.backend === "tmux" ? placed.value.mode === PANE_MODE.window ? "in a new tmux window" : "beside this terminal (tmux split)" : placed.value.backend === "pebrel" ? placed.value.mode === PANE_MODE.window ? "in a new Pebrel window" : "beside this terminal (Pebrel split)" : placed.value.mode === PANE_MODE.window ? `in the dedicated "${DEDICATED_WINDOW_NAME}" window (${placed.value.reason})` : "beside this terminal (vertical split)";
455
591
  console.log(`Map opened for ${project.root} ${where}.`);
456
592
  if (!project.found) {
457
593
  console.log(
package/dist/server.mjs CHANGED
@@ -24383,9 +24383,9 @@ function viewTool() {
24383
24383
  function openTool() {
24384
24384
  return {
24385
24385
  title: "Open the map pane",
24386
- description: 'For automatic display beside the current ChatGPT desktop conversation in Codex mode, use surface: "web-terminal", then call open_in_codex with the returned browser hostOpen object. No paste or Computer Use is needed. For the native host terminal, use surface: "codex-terminal": prepare absolute watcher commands for the current project, then ask the host to open its right terminal. This does not launch the watcher or type into that terminal. Agent exec PTYs cannot be attached using their numeric session ids. For a document panel, use surface: "markdown": generate MD + SVG files, enable automatic preview updates after successful map writes, then use the HOST file-opening tool to display the returned absolute Markdown path on the right of the current conversation. Generated does not mean visible: this server cannot open or observe the desktop side panel. For interactive maps, choose surface: "web": start or reuse a project-local web viewer and pass the returned URL to the host browser-opening tool. Markdown and terminal remain available. The default surface is "terminal", preserving the terminal workflow. Put the live map on the user\'s screen: a terminal pane beside this conversation that redraws on every write. Call it whenever a result says `pane: CLOSED` \u2014 and do NOT ask permission first, because a user who has set a mapping policy has already said they want to see the map. With a pane already open this RETARGETS it to `page` instead of opening a second one, so it is also how you show the user a particular page when they ask for one. It never closes a pane: taking the map off the screen belongs to the user (the `q` key in the pane, or typing `mmap` in a terminal). The reply says whether a pane actually reported itself in afterwards, not merely that a command was run. Automatic terminal opening supports Windows Terminal and tmux on Linux/macOS. omp (Oh My Pi) has no side panel of its own: there the routes are this same terminal pane (a Windows Terminal split, or a tmux split on Linux/macOS) and the web viewer, whose URL the session opens with its own browser tool. If opening fails, relay the reason and copyable command; retry only after the environment changes or the user asks.',
24386
+ description: 'For automatic display beside the current ChatGPT desktop conversation in Codex mode, use surface: "web-terminal", then call open_in_codex with the returned browser hostOpen object. No paste or Computer Use is needed. For the native host terminal, use surface: "codex-terminal": prepare absolute watcher commands for the current project, then ask the host to open its right terminal. This does not launch the watcher or type into that terminal. Agent exec PTYs cannot be attached using their numeric session ids. For a document panel, use surface: "markdown": generate MD + SVG files, enable automatic preview updates after successful map writes, then use the HOST file-opening tool to display the returned absolute Markdown path on the right of the current conversation. Generated does not mean visible: this server cannot open or observe the desktop side panel. For interactive maps, choose surface: "web": start or reuse a project-local web viewer and pass the returned URL to the host browser-opening tool. Markdown and terminal remain available. The default surface is "terminal", preserving the terminal workflow. Put the live map on the user\'s screen: a terminal pane beside this conversation that redraws on every write. Call it whenever a result says `pane: CLOSED` \u2014 and do NOT ask permission first, because a user who has set a mapping policy has already said they want to see the map. With a pane already open this RETARGETS it to `page` instead of opening a second one, so it is also how you show the user a particular page when they ask for one. It never closes a pane: taking the map off the screen belongs to the user (the `q` key in the pane, or typing `mmap` in a terminal). The reply says whether a pane actually reported itself in afterwards, not merely that a command was run. Automatic terminal opening supports Windows Terminal, tmux on Linux/macOS, and Pebrel, which splits the conversation\'s own pane. omp (Oh My Pi) has no side panel of its own: there the routes are this same terminal pane (a Windows Terminal split, or a tmux split on Linux/macOS) and the web viewer, whose URL the session opens with its own browser tool. If opening fails, relay the reason and copyable command; retry only after the environment changes or the user asks.',
24387
24387
  inputSchema: closed({
24388
- surface: external_exports.enum(["terminal", "codex-terminal", "markdown", "web", "web-terminal"]).optional().describe("web-terminal = automatically started mmap terminal in a local browser page; codex-terminal = prepare a command for the desktop host terminal; web = local browser viewer; markdown = MD/SVG; terminal = Windows Terminal or tmux launcher (default)"),
24388
+ surface: external_exports.enum(["terminal", "codex-terminal", "markdown", "web", "web-terminal"]).optional().describe("web-terminal = automatically started mmap terminal in a local browser page; codex-terminal = prepare a command for the desktop host terminal; web = local browser viewer; markdown = MD/SVG; terminal = Windows Terminal, tmux or Pebrel launcher (default)"),
24389
24389
  page: id(
24390
24390
  "page to show first \u2014 the page THIS effort lives on, the same slug you pass to the other tools. Omit only for the default page: without it a fresh pane opens on whichever page was written last, which after a gap is rarely the one under discussion."
24391
24391
  ).optional(),
@@ -24517,7 +24517,7 @@ function readMaps(stateFile, input) {
24517
24517
  }
24518
24518
 
24519
24519
  // src/support/version.ts
24520
- var VERSION = "0.27.1";
24520
+ var VERSION = "0.28.0";
24521
24521
 
24522
24522
  // src/server/presence.ts
24523
24523
  var DEFAULT_PAGE_NAME = "(default)";
@@ -24635,7 +24635,7 @@ Relay the launcher reason and any copyable fallback command. Do not retry mmap_o
24635
24635
  }
24636
24636
  if (paneShows(viewers, page2)) {
24637
24637
  const visible = /^MMAP_PANE [^\r\n]*\bvisibility=visible(?:\s|$)/m.test(run.output);
24638
- return `pane: running and reporting ${pageName2(page2)} \u2014 ${visible ? "the launcher verified its tmux window is active and its pane is visible in the attached session." : "terminal visibility is not confirmed by the process heartbeat."}
24638
+ return `pane: running and reporting ${pageName2(page2)} \u2014 ${visible ? "the launcher verified the terminal shows its pane: its window or tab is active and no other pane is zoomed over it." : "terminal visibility is not confirmed by the process heartbeat."}
24639
24639
  ${run.output}`;
24640
24640
  }
24641
24641
  if (viewers.length > 0) {
@@ -1788,7 +1788,7 @@ function paint(scene, opts) {
1788
1788
  }
1789
1789
 
1790
1790
  // src/support/version.ts
1791
- var VERSION = "0.27.1";
1791
+ var VERSION = "0.28.0";
1792
1792
 
1793
1793
  // src/store/atomic.ts
1794
1794
  import { mkdirSync as mkdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -2213,13 +2213,14 @@ var CLEAR_SCREEN = "\x1B[H\x1B[2J";
2213
2213
  var MOUSE_ON = "\x1B[?1003h\x1B[?1006h";
2214
2214
  var MOUSE_OFF = "\x1B[?1003l\x1B[?1006l";
2215
2215
  var RESET = "\x1B[0m";
2216
+ var COMMAND_DONE = "\x1B]133;D;0\x07";
2216
2217
  function terminalRestoreSequence(mouseActive, alternateScreen = false) {
2217
2218
  return (mouseActive ? MOUSE_OFF : "") + RESET + (alternateScreen ? LEAVE_SCREEN : "") + SHOW_CURSOR + (alternateScreen ? "" : "\n");
2218
2219
  }
2219
2220
  function openTerminalSession(options) {
2220
2221
  const { interactive, write } = options;
2221
2222
  const mouseActive = interactive && options.mouse;
2222
- write((interactive ? ENTER_SCREEN : "") + HIDE_CURSOR + CLEAR_SCREEN + (mouseActive ? MOUSE_ON : ""));
2223
+ write((interactive ? COMMAND_DONE + ENTER_SCREEN : "") + HIDE_CURSOR + CLEAR_SCREEN + (mouseActive ? MOUSE_ON : ""));
2223
2224
  const timer = mouseActive ? setInterval(() => write(HIDE_CURSOR + MOUSE_ON), 1e3) : void 0;
2224
2225
  timer?.unref();
2225
2226
  let closed = false;
@@ -2236,7 +2237,7 @@ function openTerminalSession(options) {
2236
2237
  // src/watch/frame-output.ts
2237
2238
  function frameDifference(previous, next) {
2238
2239
  const compatible = previous?.columns === next.columns;
2239
- let output2 = "";
2240
+ let output2 = previous?.title === next.title ? "" : `\x1B]2;${next.title}\x07`;
2240
2241
  for (let row = 0; row < Math.max(next.rows.length, previous?.rows.length ?? 0); row++) {
2241
2242
  const text = next.rows[row] ?? "";
2242
2243
  if (compatible && text === previous?.rows[row]) continue;
@@ -2565,6 +2566,7 @@ var CONFIRM_WINDOW_MS = 3e3;
2565
2566
  var FALLBACK_COLUMNS = 100;
2566
2567
  var FALLBACK_ROWS = 30;
2567
2568
  var DEFAULT_PAGE_TAB_LABEL = "main";
2569
+ var PANE_TITLE = "mellos map";
2568
2570
  function usableColumns(cols) {
2569
2571
  return Math.max(1, cols - 1);
2570
2572
  }
@@ -2767,7 +2769,7 @@ function mapPanel(map, unicode, width, rows = PANEL_CONTENT_ROWS) {
2767
2769
  const parts = [`${map.layers.length} layers`, `${map.nodes.length} nodes`, `${map.edges.length} edges`];
2768
2770
  if (map.lanes.length > 0) parts.push(`${map.lanes.length} lanes`);
2769
2771
  const lines = [
2770
- { text: fitWidth(map.title ?? "mellos map", width), sgr: "1" },
2772
+ { text: fitWidth(map.title ?? PANE_TITLE, width), sgr: "1" },
2771
2773
  { text: fitWidth(parts.join(" \xB7 "), width), sgr: "90" },
2772
2774
  // documentation kinds document structure, not progress
2773
2775
  { text: fitWidth(isNeutralKind(map) ? `${map.kind} diagram` : counts, width), sgr: isNeutralKind(map) ? "90" : "" },
@@ -2826,11 +2828,12 @@ function runWatcher(cfg, io = nativeWatcherIO()) {
2826
2828
  const viewWidth = () => usableColumns(io.output.columns ?? FALLBACK_COLUMNS);
2827
2829
  const viewHeight = () => Math.max(1, (io.output.rows ?? FALLBACK_ROWS) - (1 + panelContentRows) - 1 - tabRows());
2828
2830
  const dividerY = () => tabRows() + viewHeight() + 1;
2831
+ const pageTitleOf = (file) => mapOf(entryOf(pane, file))?.title ?? (pageIdOfFile(cfg.file, file) ?? DEFAULT_PAGE_TAB_LABEL);
2829
2832
  const pageTabsOf = (files) => files.map((f) => {
2830
2833
  const entry = entryOf(pane, f);
2831
2834
  const m = mapOf(entry);
2832
2835
  return {
2833
- title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? DEFAULT_PAGE_TAB_LABEL),
2836
+ title: pageTitleOf(f),
2834
2837
  status: m !== void 0 ? mapStatus(m) : "planned",
2835
2838
  active: f === pane.activeFile,
2836
2839
  fresh: entry?.fresh ?? false,
@@ -3033,7 +3036,8 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
3033
3036
  footer
3034
3037
  ];
3035
3038
  if (frameOutput !== void 0) {
3036
- frameOutput.present({ columns: cols, rows });
3039
+ const title = pane.activeFile === void 0 ? PANE_TITLE : `${PANE_TITLE} \xB7 ${pageTitleOf(pane.activeFile)}`;
3040
+ frameOutput.present({ columns: cols, rows, title });
3037
3041
  } else {
3038
3042
  const frame = HOME + rows.map((row) => row + ERASE_LINE_END).join("\n");
3039
3043
  if (frame !== lastFrame) {
package/dist/watch.mjs CHANGED
@@ -1788,7 +1788,7 @@ function paint(scene, opts) {
1788
1788
  }
1789
1789
 
1790
1790
  // src/support/version.ts
1791
- var VERSION = "0.27.1";
1791
+ var VERSION = "0.28.0";
1792
1792
 
1793
1793
  // src/store/atomic.ts
1794
1794
  import { mkdirSync as mkdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
@@ -2213,13 +2213,14 @@ var CLEAR_SCREEN = "\x1B[H\x1B[2J";
2213
2213
  var MOUSE_ON = "\x1B[?1003h\x1B[?1006h";
2214
2214
  var MOUSE_OFF = "\x1B[?1003l\x1B[?1006l";
2215
2215
  var RESET = "\x1B[0m";
2216
+ var COMMAND_DONE = "\x1B]133;D;0\x07";
2216
2217
  function terminalRestoreSequence(mouseActive, alternateScreen = false) {
2217
2218
  return (mouseActive ? MOUSE_OFF : "") + RESET + (alternateScreen ? LEAVE_SCREEN : "") + SHOW_CURSOR + (alternateScreen ? "" : "\n");
2218
2219
  }
2219
2220
  function openTerminalSession(options) {
2220
2221
  const { interactive, write } = options;
2221
2222
  const mouseActive = interactive && options.mouse;
2222
- write((interactive ? ENTER_SCREEN : "") + HIDE_CURSOR + CLEAR_SCREEN + (mouseActive ? MOUSE_ON : ""));
2223
+ write((interactive ? COMMAND_DONE + ENTER_SCREEN : "") + HIDE_CURSOR + CLEAR_SCREEN + (mouseActive ? MOUSE_ON : ""));
2223
2224
  const timer = mouseActive ? setInterval(() => write(HIDE_CURSOR + MOUSE_ON), 1e3) : void 0;
2224
2225
  timer?.unref();
2225
2226
  let closed = false;
@@ -2236,7 +2237,7 @@ function openTerminalSession(options) {
2236
2237
  // src/watch/frame-output.ts
2237
2238
  function frameDifference(previous, next) {
2238
2239
  const compatible = previous?.columns === next.columns;
2239
- let output = "";
2240
+ let output = previous?.title === next.title ? "" : `\x1B]2;${next.title}\x07`;
2240
2241
  for (let row = 0; row < Math.max(next.rows.length, previous?.rows.length ?? 0); row++) {
2241
2242
  const text = next.rows[row] ?? "";
2242
2243
  if (compatible && text === previous?.rows[row]) continue;
@@ -2566,6 +2567,7 @@ var CONFIRM_WINDOW_MS = 3e3;
2566
2567
  var FALLBACK_COLUMNS = 100;
2567
2568
  var FALLBACK_ROWS = 30;
2568
2569
  var DEFAULT_PAGE_TAB_LABEL = "main";
2570
+ var PANE_TITLE = "mellos map";
2569
2571
  function usableColumns(cols) {
2570
2572
  return Math.max(1, cols - 1);
2571
2573
  }
@@ -2768,7 +2770,7 @@ function mapPanel(map, unicode, width, rows = PANEL_CONTENT_ROWS) {
2768
2770
  const parts = [`${map.layers.length} layers`, `${map.nodes.length} nodes`, `${map.edges.length} edges`];
2769
2771
  if (map.lanes.length > 0) parts.push(`${map.lanes.length} lanes`);
2770
2772
  const lines = [
2771
- { text: fitWidth(map.title ?? "mellos map", width), sgr: "1" },
2773
+ { text: fitWidth(map.title ?? PANE_TITLE, width), sgr: "1" },
2772
2774
  { text: fitWidth(parts.join(" \xB7 "), width), sgr: "90" },
2773
2775
  // documentation kinds document structure, not progress
2774
2776
  { text: fitWidth(isNeutralKind(map) ? `${map.kind} diagram` : counts, width), sgr: isNeutralKind(map) ? "90" : "" },
@@ -2827,11 +2829,12 @@ function runWatcher(cfg, io2 = nativeWatcherIO()) {
2827
2829
  const viewWidth = () => usableColumns(io2.output.columns ?? FALLBACK_COLUMNS);
2828
2830
  const viewHeight = () => Math.max(1, (io2.output.rows ?? FALLBACK_ROWS) - (1 + panelContentRows) - 1 - tabRows());
2829
2831
  const dividerY = () => tabRows() + viewHeight() + 1;
2832
+ const pageTitleOf = (file) => mapOf(entryOf(pane, file))?.title ?? (pageIdOfFile(cfg.file, file) ?? DEFAULT_PAGE_TAB_LABEL);
2830
2833
  const pageTabsOf = (files) => files.map((f) => {
2831
2834
  const entry = entryOf(pane, f);
2832
2835
  const m = mapOf(entry);
2833
2836
  return {
2834
- title: m?.title ?? (pageIdOfFile(cfg.file, f) ?? DEFAULT_PAGE_TAB_LABEL),
2837
+ title: pageTitleOf(f),
2835
2838
  status: m !== void 0 ? mapStatus(m) : "planned",
2836
2839
  active: f === pane.activeFile,
2837
2840
  fresh: entry?.fresh ?? false,
@@ -3034,7 +3037,8 @@ the map pane stopped: ${e instanceof Error ? e.stack ?? e.message : String(e)}
3034
3037
  footer
3035
3038
  ];
3036
3039
  if (frameOutput !== void 0) {
3037
- frameOutput.present({ columns: cols, rows });
3040
+ const title = pane.activeFile === void 0 ? PANE_TITLE : `${PANE_TITLE} \xB7 ${pageTitleOf(pane.activeFile)}`;
3041
+ frameOutput.present({ columns: cols, rows, title });
3038
3042
  } else {
3039
3043
  const frame = HOME + rows.map((row) => row + ERASE_LINE_END).join("\n");
3040
3044
  if (frame !== lastFrame) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mellos-mapping",
3
- "version": "0.27.1",
3
+ "version": "0.28.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",
@@ -96,6 +96,7 @@
96
96
  "scripts/mmap.mjs",
97
97
  "scripts/open-pane.mjs",
98
98
  "scripts/pane-core.mjs",
99
+ "scripts/pebrel-session.mjs",
99
100
  "scripts/terminal-session.mjs",
100
101
  "scripts/tmux-session.mjs",
101
102
  "scripts/watcher-command.mjs"
package/scripts/mmap.mjs CHANGED
@@ -208,6 +208,8 @@ async function main() {
208
208
  const where =
209
209
  placed.value.backend === 'tmux'
210
210
  ? (placed.value.mode === PANE_MODE.window ? 'in a new tmux window' : 'beside this terminal (tmux split)')
211
+ : placed.value.backend === 'pebrel'
212
+ ? (placed.value.mode === PANE_MODE.window ? 'in a new Pebrel window' : 'beside this terminal (Pebrel split)')
211
213
  : placed.value.mode === PANE_MODE.window
212
214
  ? `in the dedicated "${DEDICATED_WINDOW_NAME}" window (${placed.value.reason})`
213
215
  : 'beside this terminal (vertical split)';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Open the live map pane in the owning Windows Terminal or tmux session — the AGENT's
3
+ * Open the live map pane in the owning Windows Terminal, tmux or Pebrel 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]
@@ -151,6 +151,10 @@ async function main() {
151
151
  console.log(`MMAP_PANE mode=${placed.value.mode} pid=${reported.value.pid} backend=tmux session=${placed.value.session} visibility=${visible.value}`);
152
152
  console.log(placed.value.mode === PANE_MODE.window
153
153
  ? 'Map opened in a new tmux window.' : 'Map opened beside this conversation (tmux split).');
154
+ } else if (placed.value.backend === 'pebrel') {
155
+ console.log(`MMAP_PANE mode=${placed.value.mode} pid=${reported.value.pid} backend=pebrel window=${placed.value.opened.window} pane=${placed.value.opened.pane} visibility=${visible.value}`);
156
+ console.log(placed.value.mode === PANE_MODE.window
157
+ ? 'Map opened in a new Pebrel window.' : 'Map opened beside this conversation (Pebrel split).');
154
158
  } else if (placed.value.mode === PANE_MODE.window) {
155
159
  console.log(`MMAP_PANE mode=window pid=${reported.value.pid} name=${DEDICATED_WINDOW_NAME} reason=${placed.value.reason}`);
156
160
  console.log(`Map opened in the dedicated "${DEDICATED_WINDOW_NAME}" window.`);
@@ -30,6 +30,7 @@
30
30
  * Pure helpers are exported for the spec; nothing here runs on import.
31
31
  */
32
32
  import * as terminal from './terminal-session.mjs';
33
+ import { createPebrelAdapter, hostedByPebrel } from './pebrel-session.mjs';
33
34
  import { createTmuxAdapter } from './tmux-session.mjs';
34
35
  import { watcherArgs } from './watcher-command.mjs';
35
36
  import { existsSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
@@ -199,18 +200,23 @@ export function selectPaneViewer(viewers, owners) {
199
200
  return viewers.find((viewer) => viewer.owner !== undefined && owners.includes(viewer.owner));
200
201
  }
201
202
 
202
- const platformTerminal = () => process.platform === 'win32' ? terminal : createTmuxAdapter();
203
+ /**
204
+ * Windows Terminal is composed here from its probe and `wt`; every other
205
+ * backend is an adapter that places and reveals its own panes.
206
+ */
207
+ const platformTerminal = () => hostedByPebrel(process.env) ? createPebrelAdapter()
208
+ : process.platform === 'win32' ? terminal : createTmuxAdapter();
203
209
 
204
210
  /** Resolve identity before deciding whether to reuse, open or toggle. */
205
211
  export function preparePane(cfg, store, mapFile, io = platformTerminal()) {
206
212
  const viewers = store.readLiveViewers(mapFile, Date.now());
207
- const inspected = io.kind === 'tmux' ? io.inspectSession(cfg, viewers) : cfg.mode === PANE_MODE.window
213
+ const inspected = io.kind !== 'wt' ? io.inspectSession(cfg, viewers) : cfg.mode === PANE_MODE.window
208
214
  ? { ok: true, value: { owners: ['window'], owner: 'window' } }
209
215
  : io.inspectSession();
210
216
  if (!inspected.ok) return inspected;
211
217
  const session = inspected.value;
212
218
  const viewer = selectPaneViewer(viewers, session.owners);
213
- if (io.kind !== 'tmux' && cfg.mode === PANE_MODE.split && !session.hwnd && (!viewer || cfg.force)) {
219
+ if (io.kind === 'wt' && cfg.mode === PANE_MODE.split && !session.hwnd && (!viewer || cfg.force)) {
214
220
  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.' };
215
221
  }
216
222
  return { ok: true, value: {
@@ -222,12 +228,12 @@ export function preparePane(cfg, store, mapFile, io = platformTerminal()) {
222
228
 
223
229
  /** A process heartbeat alone cannot establish that its pane is on screen. */
224
230
  export function revealPane(target, viewer, io = platformTerminal()) {
225
- return io.kind === 'tmux' ? io.revealPane(target, viewer) : { ok: true, value: 'unverified' };
231
+ return io.kind !== 'wt' ? io.revealPane(target, viewer) : { ok: true, value: 'unverified' };
226
232
  }
227
233
 
228
234
  /** Compose a verified target with the watcher payload. Never changes the requested mode. */
229
235
  export function placePane(cfg, watchPath, mapFile, target, io = platformTerminal()) {
230
- if (io.kind === 'tmux') return io.openPane(cfg, watchPath, mapFile, target);
236
+ if (io.kind !== 'wt') return io.openPane(cfg, watchPath, mapFile, target);
231
237
  const payload = [...paneCommand(cfg, watchPath, mapFile), '--owner', target.owner];
232
238
  if (target.mode === PANE_MODE.window) {
233
239
  const opened = io.openWt(['-w', DEDICATED_WINDOW_NAME, 'nt', ...payload], 'open the requested window');
@@ -0,0 +1,166 @@
1
+ /** Pebrel adapter: split the conversation's own pane through Pebrel's runtime CLI. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { posixQuote, watcherArgs } from './watcher-command.mjs';
5
+
6
+ const CTL_TIMEOUT_MS = 10_000;
7
+ const SPLIT_RATIO = '0.42';
8
+ const TERMINAL_TAB_KINDS = ['shell', 'profile'];
9
+
10
+ /**
11
+ * Pebrel exports PEBREL_CLI and PEBREL_PANE_ID to every local pane. A tmux
12
+ * server inherits them but is not a descendant of that pane, so a conversation
13
+ * inside tmux belongs to tmux.
14
+ */
15
+ export function hostedByPebrel(env) {
16
+ return Boolean(env.PEBREL_CLI && env.PEBREL_PANE_ID) && !env.TMUX;
17
+ }
18
+
19
+ /**
20
+ * Pebrel splits always start the default shell, so the watcher is typed as one
21
+ * line in that shell's own syntax. The line ends the shell with the watcher,
22
+ * which closes the pane the way a wt or tmux pane closes with its command.
23
+ */
24
+ export function shellCommandLine(executable, argv) {
25
+ const shell = executable.toLowerCase().replace(/\.exe$/, '');
26
+ switch (shell) {
27
+ case 'pwsh':
28
+ case 'powershell':
29
+ return { ok: true, value: `& ${argv.map(powershellQuote).join(' ')}; exit` };
30
+ case 'cmd':
31
+ return argv.some((arg) => /["%]/.test(arg))
32
+ ? { ok: false, error: 'cmd cannot quote a path containing " or %; no pane was kept.' }
33
+ : { ok: true, value: `${argv.map((arg) => `"${arg}"`).join(' ')} & exit` };
34
+ case 'sh':
35
+ case 'bash':
36
+ case 'zsh':
37
+ case 'dash':
38
+ case 'ksh':
39
+ return { ok: true, value: `exec ${argv.map(posixQuote).join(' ')}` };
40
+ default:
41
+ return { ok: false, error: `The new Pebrel pane runs ${executable}; the map starts only from PowerShell, cmd or a POSIX shell. No pane was kept.` };
42
+ }
43
+ }
44
+
45
+ /** PowerShell ends a single-quoted string at any of the four single quotes. */
46
+ function powershellQuote(arg) {
47
+ return `'${arg.replace(/['‘’‚‛]/g, '$&$&')}'`;
48
+ }
49
+
50
+ export function createPebrelAdapter({ env = process.env, spawn = spawnSync, nodePath = process.execPath, pid = process.pid } = {}) {
51
+ function ctl(command, ...args) {
52
+ const result = spawn(env.PEBREL_CLI, ['ctl', command, ...args.map(String)], {
53
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CTL_TIMEOUT_MS, windowsHide: true,
54
+ });
55
+ const reply = parseReply(result.stdout);
56
+ return reply?.ok === true
57
+ ? { ok: true, value: reply.result }
58
+ : { ok: false, error: `pebrel ctl ${command} failed: ${reply?.error?.message ?? result.error?.message ?? (result.stderr?.trim() || `exit ${result.status}`)}` };
59
+ }
60
+
61
+ function hosts(window, pane, processId) {
62
+ const tree = ctl('procs', '--window', window, '--pane', pane);
63
+ return tree.ok ? { ok: true, value: tree.value.processes.some((item) => item.pid === processId) } : tree;
64
+ }
65
+
66
+ function inspectSession(cfg) {
67
+ const snapshot = ctl('snapshot');
68
+ if (!snapshot.ok) return snapshot;
69
+ const pane = Number(env.PEBREL_PANE_ID);
70
+ // Pane ids are unique only within a window; the process tree says which window is ours.
71
+ for (const window of snapshot.value.windows) {
72
+ if (!window.tabs.some((tab) => tab.panes.some((item) => item.id === pane))) continue;
73
+ const ours = hosts(window.id, pane, pid);
74
+ if (!ours.ok) return ours;
75
+ if (!ours.value) continue;
76
+ const identity = [snapshot.value.process_id, cfg.mode, cfg.mode === 'window' ? '' : `${window.id}:${pane}`];
77
+ const owner = `pebrel-${createHash('sha256').update(JSON.stringify(identity)).digest('hex').slice(0, 24)}`;
78
+ return { ok: true, value: { backend: 'pebrel', window: window.id, pane, owner, owners: [owner] } };
79
+ }
80
+ return { ok: false, error: `This process is not running inside Pebrel pane ${env.PEBREL_PANE_ID}; no pane was opened.` };
81
+ }
82
+
83
+ function createPane(target) {
84
+ if (target.mode === 'window') return ctl('new-window');
85
+ const split = ctl('split', '--window', target.window, '--pane', target.pane, '--direction', 'right');
86
+ if (!split.ok) return split;
87
+ const created = split.value.action;
88
+ const focused = ctl('focus', '--window', target.window, '--pane', target.pane);
89
+ const resized = focused.ok
90
+ ? ctl('resize-pane', '--window', created.window_id, '--pane', created.pane_id, '--ratio', SPLIT_RATIO)
91
+ : focused;
92
+ if (resized.ok) return split;
93
+ ctl('close-pane', '--window', created.window_id, '--pane', created.pane_id);
94
+ return resized;
95
+ }
96
+
97
+ function startWatcher(cfg, watchPath, mapFile, target, created) {
98
+ const tree = ctl('procs', '--window', created.window_id, '--pane', created.pane_id);
99
+ if (!tree.ok) return tree;
100
+ const shell = tree.value.processes.find((item) => item.depth === 0)?.executable ?? 'no shell';
101
+ const line = shellCommandLine(shell, [nodePath, ...watcherArgs(cfg, watchPath, mapFile), '--owner', target.owner]);
102
+ return line.ok ? ctl('prompt', '--window', created.window_id, '--pane', created.pane_id, '--text', line.value) : line;
103
+ }
104
+
105
+ function openPane(cfg, watchPath, mapFile, target) {
106
+ const created = createPane(target);
107
+ if (!created.ok) return created;
108
+ const pane = created.value.action;
109
+ const started = startWatcher(cfg, watchPath, mapFile, target, pane);
110
+ if (started.ok) return { ok: true, value: { ...target, opened: { window: pane.window_id, pane: pane.pane_id } } };
111
+ ctl('close-pane', '--window', pane.window_id, '--pane', pane.pane_id);
112
+ return started;
113
+ }
114
+
115
+ function locate(processId) {
116
+ const snapshot = ctl('snapshot');
117
+ if (!snapshot.ok) return snapshot;
118
+ for (const window of snapshot.value.windows) {
119
+ for (const tab of window.tabs.filter((item) => TERMINAL_TAB_KINDS.includes(item.kind))) {
120
+ for (const pane of tab.panes.filter((item) => item.ssh_destination === null)) {
121
+ const found = hosts(window.id, pane.id, processId);
122
+ if (!found.ok) return found;
123
+ if (found.value) return { ok: true, value: { window, tab, pane } };
124
+ }
125
+ }
126
+ }
127
+ return { ok: false, error: 'The live watcher could not be located in any Pebrel pane; visibility is unverified.' };
128
+ }
129
+
130
+ function revealPane(target, viewer) {
131
+ const located = locate(viewer.pid);
132
+ if (!located.ok) return located;
133
+ const { window, tab, pane } = located.value;
134
+ if (zoomedOver(tab, pane.id)) {
135
+ const unzoomed = ctl('zoom-pane', '--window', window.id, '--pane', tab.zoomed_pane_id, '--zoomed', 'false');
136
+ if (!unzoomed.ok) return unzoomed;
137
+ }
138
+ if (!tab.active) {
139
+ // A split shares the conversation's tab, so focusing the conversation reveals it without taking input.
140
+ const focused = ctl('focus', '--window', window.id, '--pane', target.mode === 'window' ? pane.id : target.pane);
141
+ if (!focused.ok) return focused;
142
+ }
143
+ const after = ctl('snapshot');
144
+ if (!after.ok) return after;
145
+ const shown = after.value.windows.find((item) => item.id === window.id)
146
+ ?.tabs.find((item) => item.panes.some((member) => member.id === pane.id));
147
+ return shown?.active && !zoomedOver(shown, pane.id)
148
+ ? { ok: true, value: 'visible' }
149
+ : { ok: false, error: 'The watcher is running, but its Pebrel pane is not visible in its window.' };
150
+ }
151
+
152
+ return { kind: 'pebrel', inspectSession, openPane, revealPane };
153
+ }
154
+
155
+ /** Pebrel 2.2 omits zoomed_pane_id from a tab that has no zoom. */
156
+ function zoomedOver(tab, paneId) {
157
+ return tab.zoomed_pane_id !== undefined && tab.zoomed_pane_id !== null && tab.zoomed_pane_id !== paneId;
158
+ }
159
+
160
+ function parseReply(stdout) {
161
+ try {
162
+ return JSON.parse(stdout);
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
@@ -1,6 +1,8 @@
1
1
  /** Windows Terminal adapter. No project paths, reuse policy or persistent state. */
2
2
  import { spawnSync } from 'node:child_process';
3
3
 
4
+ export const kind = 'wt';
5
+
4
6
  const NATIVE = String.raw`
5
7
  $ErrorActionPreference = 'Stop'
6
8
  $ProgressPreference = 'SilentlyContinue'
@@ -5,10 +5,13 @@ export function watcherArgs(cfg, watchPath, mapFile) {
5
5
  return args;
6
6
  }
7
7
 
8
- /** POSIX shell quoting is only for the human fallback; adapters use argv. */
8
+ export function posixQuote(arg) {
9
+ return "'" + arg.replaceAll("'", "'\"'\"'") + "'";
10
+ }
11
+
12
+ /** The human fallback; adapters that can pass argv never quote. */
9
13
  export function manualWatcherCommand(cfg, watchPath, mapFile, nodePath = process.execPath) {
10
- return [nodePath, ...watcherArgs(cfg, watchPath, mapFile)]
11
- .map(arg => "'" + arg.replaceAll("'", "'\"'\"'") + "'").join(' ');
14
+ return [nodePath, ...watcherArgs(cfg, watchPath, mapFile)].map(posixQuote).join(' ');
12
15
  }
13
16
 
14
17
  export function paneFailureMessage(error, cfg, watchPath, mapFile, platform = process.platform) {
@@ -204,6 +204,9 @@ confirmed open desktop panel. See `docs/codex.md` for transport and lifecycle de
204
204
  attached sessions require `MELLOS_MAPPING_TMUX_TARGET`; custom sockets use
205
205
  `MELLOS_MAPPING_TMUX_SOCKET` in the MCP server environment. `window: true`
206
206
  requests a new tmux window. Do not guess between attached sessions.
207
+ Inside Pebrel (`PEBREL_CLI` and `PEBREL_PANE_ID` set, not inside tmux) it
208
+ splits the conversation's own Pebrel pane; `window: true` opens a new
209
+ Pebrel window.
207
210
  Under omp (Oh My Pi) nothing about this changes: the same tool runs the same
208
211
  launcher, splitting the Windows Terminal window hosting the omp session
209
212
  (tmux split on Linux/macOS) and reusing the pane already bound to it. omp has