pi-terminal-mux 0.2.1 → 0.3.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 +6 -5
- package/README.zh-CN.md +6 -5
- package/locales/mux.json +6 -2
- package/package.json +5 -5
- package/src/backends/cmux.ts +18 -2
- package/src/backends/herdr.ts +59 -185
- package/src/backends/muxy.ts +48 -134
- package/src/backends/orca.ts +380 -0
- package/src/backends/otty.ts +88 -238
- package/src/backends/shared.ts +250 -0
- package/src/backends/tmux.ts +8 -0
- package/src/backends/wezterm.ts +8 -0
- package/src/backends/zellij.ts +7 -39
- package/src/detection.ts +16 -37
- package/src/index.ts +24 -1
- package/src/mux.ts +2 -0
- package/src/orca.ts +24 -0
- package/src/surface.ts +17 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-terminal-mux
|
|
2
2
|
|
|
3
|
-
Terminal multiplexer abstraction for pi extensions — one unified surface API across **muxy, cmux, tmux, zellij, wezterm, herdr and
|
|
3
|
+
Terminal multiplexer abstraction for pi extensions — one unified surface API across **muxy, cmux, tmux, zellij, wezterm, herdr, otty and orca**, with automatic **headless fallback** (background child process + log file) when no multiplexer is detected.
|
|
4
4
|
|
|
5
5
|
Any pi extension that needs terminal interaction (splitting panes, sending commands, reading screens, closing panes, waiting for process exit) should depend on this package instead of re-implementing backend detection and command assembly.
|
|
6
6
|
|
|
@@ -57,10 +57,11 @@ closeSurface(surface);
|
|
|
57
57
|
| wezterm | `WEZTERM_UNIX_SOCKET` + `wezterm` command |
|
|
58
58
|
| herdr | `HERDR_ENV=1` + `HERDR_PANE_ID` + `herdr` command |
|
|
59
59
|
| otty | `TERM_PROGRAM=otty` + `otty` command |
|
|
60
|
+
| orca | `TERM_PROGRAM=Orca` + `orca` command + reachable Orca runtime |
|
|
60
61
|
|
|
61
62
|
Default priority follows the table order (muxy first). Force a backend with:
|
|
62
63
|
|
|
63
|
-
- `PI_TERMINAL_MUX` (preferred): `muxy | cmux | tmux | zellij | wezterm | herdr | otty`
|
|
64
|
+
- `PI_TERMINAL_MUX` (preferred): `muxy | cmux | tmux | zellij | wezterm | herdr | otty | orca`
|
|
64
65
|
- `PI_SUBAGENT_MUX`: backward-compatible alias
|
|
65
66
|
|
|
66
67
|
If the forced backend's runtime is unavailable, `getMuxBackend()` returns `null` — it never silently falls back to another backend.
|
|
@@ -71,7 +72,7 @@ If the forced backend's runtime is unavailable, `getMuxBackend()` returns `null`
|
|
|
71
72
|
|
|
72
73
|
| Function | Description |
|
|
73
74
|
|----------|-------------|
|
|
74
|
-
| `createSurface(name)` | Smart placement (cmux: first right-split then tabs; zellij: tab-aware tiled/stacked; muxy/otty: breadth-first splits), returns a surface handle |
|
|
75
|
+
| `createSurface(name)` | Smart placement (cmux: first right-split then tabs; zellij: tab-aware tiled/stacked; muxy/otty: breadth-first splits; orca: new tab in the current worktree), returns a surface handle |
|
|
75
76
|
| `createSurfaceSplit(name, direction, fromSurface?)` | Split in an explicit direction (left/right/up/down) |
|
|
76
77
|
| `sendCommand(surface, command)` | Send a command and press Enter |
|
|
77
78
|
| `sendLongCommand(surface, command, opts?)` | Write long commands to a script file first; `opts.scriptPreamble` injects env exports; returns the script path |
|
|
@@ -88,7 +89,7 @@ If the forced backend's runtime is unavailable, `getMuxBackend()` returns `null`
|
|
|
88
89
|
|
|
89
90
|
### Backend-native APIs
|
|
90
91
|
|
|
91
|
-
Backend-native functions are also re-exported (e.g. `createHerdrSurface`, `splitHerdrPane`, `readHerdrScreen`, `sendOttyCommand`, `renameOttyTab`, ...). Subpath imports are available too: `pi-terminal-mux/mux`, `pi-terminal-mux/herdr`, `pi-terminal-mux/otty`.
|
|
92
|
+
Backend-native functions are also re-exported (e.g. `createHerdrSurface`, `splitHerdrPane`, `readHerdrScreen`, `sendOttyCommand`, `renameOttyTab`, `createOrcaSurface`, `sendOrcaCommand`, ...). Subpath imports are available too: `pi-terminal-mux/mux`, `pi-terminal-mux/herdr`, `pi-terminal-mux/otty`, `pi-terminal-mux/orca`.
|
|
92
93
|
|
|
93
94
|
## Headless mode
|
|
94
95
|
|
|
@@ -108,7 +109,7 @@ When no backend is detected, `createSurface` returns a `headless:`-prefixed surf
|
|
|
108
109
|
|
|
109
110
|
- **No machine coupling**: every backend is selected via runtime detection (env vars + command availability); no hardcoded local paths; missing CLIs degrade backend-by-backend down to headless.
|
|
110
111
|
- **Localized user-facing text**: setup hints go through the [pi-extensions-i18n](https://www.npmjs.com/package/pi-extensions-i18n) catalog with complete `zh-CN` and `en-US` entries.
|
|
111
|
-
- **Agent pane anchoring**: the agent's own pane ID on muxy/herdr/otty is captured at module load (`AGENT_MUXY_PANE_ID` etc.), immune to later focus switches.
|
|
112
|
+
- **Agent pane anchoring**: the agent's own pane ID on muxy/herdr/otty/orca is captured at module load (`AGENT_MUXY_PANE_ID`, `AGENT_ORCA_TERMINAL_HANDLE` etc.), immune to later focus switches.
|
|
112
113
|
|
|
113
114
|
## License
|
|
114
115
|
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
终端多路复用器统一抽象层,供 pi 扩展复用。任何涉及终端交互(分屏、发命令、读屏、关屏、等待退出)的插件都应依赖本包,而不是各自重新实现 backend 探测与命令拼装。
|
|
4
4
|
|
|
5
|
-
一套统一的 surface API 跨 **muxy、cmux、tmux、zellij、wezterm、herdr、otty**
|
|
5
|
+
一套统一的 surface API 跨 **muxy、cmux、tmux、zellij、wezterm、herdr、otty、orca** 八个后端,探测不到任何后端时自动降级为 **headless**(后台子进程 + 日志文件)。
|
|
6
6
|
|
|
7
7
|
[English README](./README.md)
|
|
8
8
|
|
|
@@ -56,10 +56,11 @@ closeSurface(surface);
|
|
|
56
56
|
| wezterm | `WEZTERM_UNIX_SOCKET` + `wezterm` 命令 |
|
|
57
57
|
| herdr | `HERDR_ENV=1` + `HERDR_PANE_ID` + `herdr` 命令 |
|
|
58
58
|
| otty | `TERM_PROGRAM=otty` + `otty` 命令 |
|
|
59
|
+
| orca | `TERM_PROGRAM=Orca` + `orca` 命令 + Orca runtime 可达 |
|
|
59
60
|
|
|
60
61
|
默认优先级即上表顺序(muxy 优先)。可用环境变量强制指定后端:
|
|
61
62
|
|
|
62
|
-
- `PI_TERMINAL_MUX`(推荐):`muxy | cmux | tmux | zellij | wezterm | herdr | otty`
|
|
63
|
+
- `PI_TERMINAL_MUX`(推荐):`muxy | cmux | tmux | zellij | wezterm | herdr | otty | orca`
|
|
63
64
|
- `PI_SUBAGENT_MUX`:同上的向后兼容别名
|
|
64
65
|
|
|
65
66
|
指定的后端运行环境不满足时 `getMuxBackend()` 返回 `null`,不会悄悄降级到其他后端。
|
|
@@ -70,7 +71,7 @@ closeSurface(surface);
|
|
|
70
71
|
|
|
71
72
|
| 函数 | 说明 |
|
|
72
73
|
|------|------|
|
|
73
|
-
| `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty
|
|
74
|
+
| `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty 广度优先分屏、orca 在当前 worktree 新建 tab),返回 surface 标识 |
|
|
74
75
|
| `createSurfaceSplit(name, direction, fromSurface?)` | 指定方向(left/right/up/down)分屏 |
|
|
75
76
|
| `sendCommand(surface, command)` | 发送命令并回车执行 |
|
|
76
77
|
| `sendLongCommand(surface, command, opts?)` | 长命令先写脚本文件再执行;`opts.scriptPreamble` 可注入 env export;返回脚本路径 |
|
|
@@ -87,7 +88,7 @@ closeSurface(surface);
|
|
|
87
88
|
|
|
88
89
|
### 后端原生 API
|
|
89
90
|
|
|
90
|
-
各后端原生函数也从包入口透出(如 `createHerdrSurface`、`splitHerdrPane`、`readHerdrScreen`、`sendOttyCommand`、`renameOttyTab`……),子路径导入亦可:`pi-terminal-mux/mux`、`pi-terminal-mux/herdr`、`pi-terminal-mux/otty`。
|
|
91
|
+
各后端原生函数也从包入口透出(如 `createHerdrSurface`、`splitHerdrPane`、`readHerdrScreen`、`sendOttyCommand`、`renameOttyTab`、`createOrcaSurface`、`sendOrcaCommand`……),子路径导入亦可:`pi-terminal-mux/mux`、`pi-terminal-mux/herdr`、`pi-terminal-mux/otty`、`pi-terminal-mux/orca`。
|
|
91
92
|
|
|
92
93
|
## Headless 模式
|
|
93
94
|
|
|
@@ -107,7 +108,7 @@ closeSurface(surface);
|
|
|
107
108
|
|
|
108
109
|
- **不绑定具体机器**:全部后端通过运行时探测(环境变量 + 命令存在性)选择,零硬编码本机路径;外部 CLI 缺失时按后端逐个降级,最终落到 headless。
|
|
109
110
|
- **用户文案国际化**:面向用户的提示走 [pi-extensions-i18n](https://www.npmjs.com/package/pi-extensions-i18n) catalog,中英文齐全。
|
|
110
|
-
- **agent pane 锚定**:muxy/herdr/otty 的 agent 自身 pane ID 在模块加载时捕获(`AGENT_MUXY_PANE_ID` 等),不受用户后续焦点切换影响。
|
|
111
|
+
- **agent pane 锚定**:muxy/herdr/otty/orca 的 agent 自身 pane ID 在模块加载时捕获(`AGENT_MUXY_PANE_ID`、`AGENT_ORCA_TERMINAL_HANDLE` 等),不受用户后续焦点切换影响。
|
|
111
112
|
|
|
112
113
|
## License
|
|
113
114
|
|
package/locales/mux.json
CHANGED
|
@@ -31,9 +31,13 @@
|
|
|
31
31
|
"zh-CN": "请在 Otty 中运行 pi(Otty 会自动设置 TERM_PROGRAM=otty)。",
|
|
32
32
|
"en-US": "Run pi inside Otty (Otty sets TERM_PROGRAM=otty automatically)."
|
|
33
33
|
},
|
|
34
|
+
"setupHint.orca": {
|
|
35
|
+
"zh-CN": "请在 Orca 中运行 pi(Orca 会自动设置 TERM_PROGRAM=Orca 和 ORCA_TERMINAL_HANDLE)。",
|
|
36
|
+
"en-US": "Run pi inside Orca (Orca sets TERM_PROGRAM=Orca and ORCA_TERMINAL_HANDLE automatically)."
|
|
37
|
+
},
|
|
34
38
|
"setupHint.generic": {
|
|
35
|
-
"zh-CN": "请在 Muxy、cmux(`cmux pi`)、tmux(`tmux new -A -s pi 'pi'`)、zellij(`zellij --session pi`,然后运行 `pi`)、WezTerm、herdr(运行 `herdr`,拆分 pane 后在其中运行 `pi
|
|
36
|
-
"en-US": "Start pi inside Muxy, cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), WezTerm, herdr (run `herdr`, split a pane, then run `pi` in it),
|
|
39
|
+
"zh-CN": "请在 Muxy、cmux(`cmux pi`)、tmux(`tmux new -A -s pi 'pi'`)、zellij(`zellij --session pi`,然后运行 `pi`)、WezTerm、herdr(运行 `herdr`,拆分 pane 后在其中运行 `pi`)、Otty(Otty 会自动设置 TERM_PROGRAM=otty)或 Orca(Orca 会自动设置 TERM_PROGRAM=Orca)中启动 pi。",
|
|
40
|
+
"en-US": "Start pi inside Muxy, cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), WezTerm, herdr (run `herdr`, split a pane, then run `pi` in it), Otty (Otty sets TERM_PROGRAM=otty automatically), or Orca (Orca sets TERM_PROGRAM=Orca automatically)."
|
|
37
41
|
},
|
|
38
42
|
"setupHint.herdrPreferred": {
|
|
39
43
|
"zh-CN": "请在 herdr 中启动 pi(必须设置 HERDR_ENV=1;先运行 herdr,再在 pane 中启动 pi)。",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-terminal-mux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Terminal multiplexer abstraction for pi extensions — unified surface API across muxy, cmux, tmux, zellij, wezterm, herdr and otty, with headless fallback",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"./package.json": "./package.json",
|
|
10
10
|
"./mux": "./src/mux.ts",
|
|
11
11
|
"./herdr": "./src/herdr.ts",
|
|
12
|
-
"./otty": "./src/otty.ts"
|
|
12
|
+
"./otty": "./src/otty.ts",
|
|
13
|
+
"./orca": "./src/orca.ts"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"index.ts",
|
|
@@ -52,12 +53,11 @@
|
|
|
52
53
|
"wezterm",
|
|
53
54
|
"coding-agent"
|
|
54
55
|
],
|
|
55
|
-
"
|
|
56
|
-
"pi-extensions-i18n": "
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"pi-extensions-i18n": "^0.3.1"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
60
|
"@types/node": "24.12.4",
|
|
60
|
-
"pi-extensions-i18n": "^0.3.0",
|
|
61
61
|
"tsx": "4.23.1",
|
|
62
62
|
"typescript": "5.9.3"
|
|
63
63
|
}
|
package/src/backends/cmux.ts
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
import { execSync, execFileSync, spawnSync } from "node:child_process";
|
|
10
10
|
import { shellEscape } from "../shell.ts";
|
|
11
|
+
import { createBackendLogger } from "./shared.ts";
|
|
11
12
|
import type { BackendOps } from "./types.ts";
|
|
12
13
|
|
|
14
|
+
/** Cmux 后端日志(统一格式,写入 /tmp/pi-mux-cmux.log) */
|
|
15
|
+
const cmuxLog = createBackendLogger("cmux", "/tmp/pi-mux-cmux.log");
|
|
16
|
+
|
|
13
17
|
// ── 内部辅助 ──
|
|
14
18
|
|
|
15
19
|
/** Tracked subagent pane for cmux — reused across subagent launches. */
|
|
@@ -271,7 +275,11 @@ export const ops: BackendOps = {
|
|
|
271
275
|
try {
|
|
272
276
|
const tree = execSync(`cmux tree`, { encoding: "utf8" });
|
|
273
277
|
if (tree.includes(cmuxSubagentPane)) {
|
|
274
|
-
|
|
278
|
+
const surface = createSurfaceInPane(name, cmuxSubagentPane);
|
|
279
|
+
cmuxLog(
|
|
280
|
+
`[split] mode=tab-reuse pane=${cmuxSubagentPane} new=${surface} name=${JSON.stringify(name)}`,
|
|
281
|
+
);
|
|
282
|
+
return surface;
|
|
275
283
|
}
|
|
276
284
|
} catch {}
|
|
277
285
|
// Pane 已消失 — fall through 创建新 split
|
|
@@ -280,11 +288,18 @@ export const ops: BackendOps = {
|
|
|
280
288
|
|
|
281
289
|
const created = createCmuxSplitSurface(name, "right", process.env.CMUX_SURFACE_ID);
|
|
282
290
|
cmuxSubagentPane = created.paneRef ?? null;
|
|
291
|
+
cmuxLog(
|
|
292
|
+
`[split] mode=first dir=right from=${process.env.CMUX_SURFACE_ID ?? "<unset>"} new=${created.surface} name=${JSON.stringify(name)}`,
|
|
293
|
+
);
|
|
283
294
|
return created.surface;
|
|
284
295
|
},
|
|
285
296
|
|
|
286
297
|
createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string): string {
|
|
287
|
-
|
|
298
|
+
const surface = createCmuxSplitSurface(name, direction, fromSurface).surface;
|
|
299
|
+
cmuxLog(
|
|
300
|
+
`[split] mode=createSurfaceSplit dir=${direction} from=${fromSurface ?? "<unset>"} new=${surface} name=${JSON.stringify(name)}`,
|
|
301
|
+
);
|
|
302
|
+
return surface;
|
|
288
303
|
},
|
|
289
304
|
|
|
290
305
|
send(surface: string, command: string): void {
|
|
@@ -318,6 +333,7 @@ export const ops: BackendOps = {
|
|
|
318
333
|
execSync(`cmux close-surface --surface ${shellEscape(surface)}`, {
|
|
319
334
|
encoding: "utf8",
|
|
320
335
|
});
|
|
336
|
+
cmuxLog(`[close] surface=${surface}`);
|
|
321
337
|
},
|
|
322
338
|
|
|
323
339
|
rename(surface: string, name: string): void {
|
package/src/backends/herdr.ts
CHANGED
|
@@ -9,21 +9,16 @@
|
|
|
9
9
|
* HERDR_PANE_ID(公开 id,如 "1-1")
|
|
10
10
|
*
|
|
11
11
|
* 所有 pane 操作通过 `herdr` CLI 完成,详见 SKILL.md。
|
|
12
|
+
* 日志、文件锁、BFS 分屏状态机、命令检测复用 backends/shared.ts。
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
import { execFileSync
|
|
15
|
-
import {
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { rmSync } from "node:fs";
|
|
16
17
|
import { i18n } from "../i18n.ts";
|
|
18
|
+
import { createBackendLogger, withFileLock, BfsSplitStateManager, hasCommand } from "./shared.ts";
|
|
17
19
|
|
|
18
|
-
// ──
|
|
19
|
-
const
|
|
20
|
-
function herdrLog(msg: string): void {
|
|
21
|
-
try {
|
|
22
|
-
appendFileSync(HERDR_SPLIT_LOG, `[${new Date().toISOString()}] ${msg}`);
|
|
23
|
-
} catch {
|
|
24
|
-
/* 写日志失败不影响主流程 */
|
|
25
|
-
}
|
|
26
|
-
}
|
|
20
|
+
// ── 日志(统一格式,写入 /tmp/pi-mux-herdr.log) ──
|
|
21
|
+
const herdrLog = createBackendLogger("herdr", "/tmp/pi-mux-herdr.log");
|
|
27
22
|
|
|
28
23
|
/**
|
|
29
24
|
* 捕获于模块加载时的 agent pane id。
|
|
@@ -34,27 +29,6 @@ export const AGENT_HERDR_PANE_ID = process.env.HERDR_PANE_ID;
|
|
|
34
29
|
export const AGENT_HERDR_WORKSPACE_ID = process.env.HERDR_WORKSPACE_ID;
|
|
35
30
|
export const AGENT_HERDR_TAB_ID = process.env.HERDR_TAB_ID;
|
|
36
31
|
|
|
37
|
-
// ── 命令可用性缓存 ──
|
|
38
|
-
|
|
39
|
-
const commandAvailability = new Map<string, boolean>();
|
|
40
|
-
|
|
41
|
-
function hasCommand(command: string): boolean {
|
|
42
|
-
if (commandAvailability.has(command)) {
|
|
43
|
-
return commandAvailability.get(command)!;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let available = false;
|
|
47
|
-
try {
|
|
48
|
-
execFileSync("which", [command], { stdio: "ignore" });
|
|
49
|
-
available = true;
|
|
50
|
-
} catch {
|
|
51
|
-
available = false;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
commandAvailability.set(command, available);
|
|
55
|
-
return available;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
32
|
/**
|
|
59
33
|
* 检测 herdr backend 是否可用:
|
|
60
34
|
* 1. `herdr` 命令在 PATH 中
|
|
@@ -77,12 +51,12 @@ export function isHerdrRuntimeAvailable(): boolean {
|
|
|
77
51
|
|
|
78
52
|
/**
|
|
79
53
|
* 调用 `herdr` 命令并返回 stdout。
|
|
80
|
-
*
|
|
54
|
+
* 失败时原样抛错(调用方决定如何处理)。
|
|
81
55
|
*/
|
|
82
56
|
function herdrExec(args: string[]): string {
|
|
83
|
-
herdrLog(`[
|
|
57
|
+
herdrLog(`[exec] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
|
|
84
58
|
const out = execFileSync("herdr", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
85
|
-
herdrLog(`[
|
|
59
|
+
herdrLog(`[exec] -> ${JSON.stringify(out.trim().slice(0, 200))}`);
|
|
86
60
|
return out;
|
|
87
61
|
}
|
|
88
62
|
|
|
@@ -91,7 +65,7 @@ function herdrExec(args: string[]): string {
|
|
|
91
65
|
* 无输出的命令,遵循 SKILL.md 中"pane send-text/send-keys/run print nothing on success"。
|
|
92
66
|
*/
|
|
93
67
|
function herdrExecSilent(args: string[]): void {
|
|
94
|
-
herdrLog(`[
|
|
68
|
+
herdrLog(`[exec silent] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
|
|
95
69
|
execFileSync("herdr", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
96
70
|
}
|
|
97
71
|
|
|
@@ -125,12 +99,12 @@ function extractPaneId(json: unknown): string | null {
|
|
|
125
99
|
return null;
|
|
126
100
|
}
|
|
127
101
|
|
|
128
|
-
// ──
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
102
|
+
// ── BFS 分屏状态 marker 路径 ──
|
|
103
|
+
|
|
104
|
+
/** herdr BFS 分屏状态 marker 文件路径(按 agent pane id 区分) */
|
|
105
|
+
function herdrMarkerPath(): string {
|
|
106
|
+
return `/tmp/herdr-subagent-pane-${(AGENT_HERDR_PANE_ID ?? "default").replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
|
|
107
|
+
}
|
|
134
108
|
|
|
135
109
|
// ── 对外 API:createSurface 系列 ──
|
|
136
110
|
|
|
@@ -139,6 +113,7 @@ const herdrPaneSources = new Map<string, string>();
|
|
|
139
113
|
*
|
|
140
114
|
* 实现:split 当前 agent pane 右侧(--no-focus 保持 agent 焦点不变)。
|
|
141
115
|
* 后续 subagent 按 breadth-first 模式轮转 right/down/right/down…(与 cmux/muxy 行为一致)。
|
|
116
|
+
* 锁与 BFS 状态机复用 shared.ts。
|
|
142
117
|
*/
|
|
143
118
|
export function createHerdrSurface(name: string): string {
|
|
144
119
|
if (!AGENT_HERDR_PANE_ID) {
|
|
@@ -148,142 +123,66 @@ export function createHerdrSurface(name: string): string {
|
|
|
148
123
|
);
|
|
149
124
|
}
|
|
150
125
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// 第二轮:从第一个 pane 向下分,pos=0, base=1
|
|
154
|
-
// 第三轮:从第一个 pane 向右、第二个 pane 向右,pos=0, base=2
|
|
155
|
-
// ……
|
|
156
|
-
// 状态文件:/tmp/herdr-subagent-pane-<agent_pane_id>.json
|
|
157
|
-
const markerFile = `/tmp/herdr-subagent-pane-${AGENT_HERDR_PANE_ID.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
|
|
158
|
-
const lockFile = `${markerFile}.lock`;
|
|
159
|
-
|
|
160
|
-
// 全局锁:所有分屏操作串行化
|
|
161
|
-
const acquired = (() => {
|
|
162
|
-
for (let i = 0; i < 60; i++) {
|
|
163
|
-
if (!existsSync(lockFile)) {
|
|
164
|
-
try {
|
|
165
|
-
writeFileSync(lockFile, `${process.pid}`, { flag: "wx" });
|
|
166
|
-
return true;
|
|
167
|
-
} catch {
|
|
168
|
-
// 竞争失败,继续等待
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
spawnSync("sleep", ["0.05"]);
|
|
172
|
-
}
|
|
173
|
-
return false;
|
|
174
|
-
})();
|
|
126
|
+
const markerFile = herdrMarkerPath();
|
|
127
|
+
const lockPath = `${markerFile}.lock`;
|
|
175
128
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return "";
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
let state: { panes: string[]; pos: number; base: number; dir: "right" | "down" } = {
|
|
183
|
-
panes: [],
|
|
184
|
-
pos: 0,
|
|
185
|
-
base: 0,
|
|
186
|
-
dir: "right",
|
|
187
|
-
};
|
|
188
|
-
try {
|
|
189
|
-
state = JSON.parse(readFileSync(markerFile, "utf8"));
|
|
190
|
-
} catch {
|
|
191
|
-
/* 文件不存在或损坏,用初始状态 */
|
|
192
|
-
}
|
|
129
|
+
return withFileLock(lockPath, {}, () => {
|
|
130
|
+
const state = new BfsSplitStateManager(markerFile);
|
|
193
131
|
|
|
194
132
|
// 首次 split
|
|
195
|
-
if (state.panes.length === 0) {
|
|
133
|
+
if (state.panes().length === 0) {
|
|
196
134
|
const output = herdrExec([
|
|
197
135
|
"pane",
|
|
198
136
|
"split",
|
|
199
|
-
AGENT_HERDR_PANE_ID
|
|
137
|
+
AGENT_HERDR_PANE_ID!,
|
|
200
138
|
"--direction",
|
|
201
139
|
"right",
|
|
202
140
|
"--no-focus",
|
|
203
141
|
]);
|
|
204
|
-
const
|
|
205
|
-
const newPaneId = extractPaneId(json);
|
|
142
|
+
const newPaneId = extractPaneId(parseHerdrJson(output));
|
|
206
143
|
if (newPaneId) {
|
|
207
|
-
state.
|
|
208
|
-
state.pos = 0;
|
|
209
|
-
state.base = 1;
|
|
210
|
-
state.dir = "down";
|
|
211
|
-
writeFileSync(markerFile, JSON.stringify(state));
|
|
212
|
-
herdrPaneSources.set(newPaneId, AGENT_HERDR_PANE_ID);
|
|
144
|
+
state.add(newPaneId);
|
|
213
145
|
renameHerdrPane(newPaneId, name);
|
|
214
146
|
herdrLog(
|
|
215
|
-
`[
|
|
147
|
+
`[split] mode=first dir=right from=${AGENT_HERDR_PANE_ID} new=${newPaneId} name=${JSON.stringify(name)}`,
|
|
216
148
|
);
|
|
217
149
|
return newPaneId;
|
|
218
150
|
}
|
|
219
|
-
herdrLog(`[
|
|
151
|
+
herdrLog(`[split] first split returned no pane id, output=${JSON.stringify(output)}`);
|
|
220
152
|
return "";
|
|
221
153
|
}
|
|
222
154
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
state.base = state.panes.length;
|
|
227
|
-
state.dir = state.dir === "right" ? "down" : "right";
|
|
228
|
-
}
|
|
155
|
+
// 后续:BFS 分屏
|
|
156
|
+
const next = state.next();
|
|
157
|
+
if (!next) return "";
|
|
229
158
|
|
|
230
|
-
let
|
|
231
|
-
|
|
232
|
-
herdrLog(`[herdr split] state.panes[${state.pos}] is undefined\n`);
|
|
233
|
-
return "";
|
|
234
|
-
}
|
|
159
|
+
let { source } = next;
|
|
160
|
+
const { direction } = next;
|
|
235
161
|
|
|
236
|
-
// 若
|
|
162
|
+
// 若 source 过期(pane 被关闭 / session 重启),重置状态从 agent pane 重新分屏
|
|
237
163
|
let output: string;
|
|
238
|
-
let sourcePane = targetPane;
|
|
239
164
|
try {
|
|
240
|
-
output = herdrExec([
|
|
241
|
-
"pane",
|
|
242
|
-
"split",
|
|
243
|
-
targetPane,
|
|
244
|
-
"--direction",
|
|
245
|
-
state.dir,
|
|
246
|
-
"--no-focus",
|
|
247
|
-
]);
|
|
165
|
+
output = herdrExec(["pane", "split", source, "--direction", direction, "--no-focus"]);
|
|
248
166
|
} catch {
|
|
249
167
|
try { rmSync(markerFile); } catch { /* ignore */ }
|
|
250
|
-
herdrLog(
|
|
251
|
-
|
|
252
|
-
);
|
|
253
|
-
state = { panes: [], pos: 0, base: 0, dir: "right" };
|
|
254
|
-
targetPane = AGENT_HERDR_PANE_ID;
|
|
255
|
-
sourcePane = AGENT_HERDR_PANE_ID;
|
|
256
|
-
output = herdrExec([
|
|
257
|
-
"pane",
|
|
258
|
-
"split",
|
|
259
|
-
AGENT_HERDR_PANE_ID,
|
|
260
|
-
"--direction",
|
|
261
|
-
"right",
|
|
262
|
-
"--no-focus",
|
|
263
|
-
]);
|
|
168
|
+
herdrLog(`[split] pane ${source} gone, resetting from agent pane ${AGENT_HERDR_PANE_ID}`);
|
|
169
|
+
source = AGENT_HERDR_PANE_ID!;
|
|
170
|
+
output = herdrExec(["pane", "split", source, "--direction", "right", "--no-focus"]);
|
|
264
171
|
}
|
|
265
|
-
|
|
266
|
-
const newPaneId = extractPaneId(
|
|
172
|
+
|
|
173
|
+
const newPaneId = extractPaneId(parseHerdrJson(output));
|
|
267
174
|
if (newPaneId) {
|
|
268
|
-
state.
|
|
269
|
-
state.
|
|
270
|
-
writeFileSync(markerFile, JSON.stringify(state));
|
|
271
|
-
herdrPaneSources.set(newPaneId, sourcePane);
|
|
175
|
+
state.advance();
|
|
176
|
+
state.add(newPaneId);
|
|
272
177
|
renameHerdrPane(newPaneId, name);
|
|
273
178
|
herdrLog(
|
|
274
|
-
`[
|
|
179
|
+
`[split] mode=next dir=${direction} from=${source} new=${newPaneId} name=${JSON.stringify(name)}`,
|
|
275
180
|
);
|
|
276
181
|
return newPaneId;
|
|
277
182
|
}
|
|
278
|
-
herdrLog(`[
|
|
183
|
+
herdrLog(`[split] next split returned no pane id, output=${JSON.stringify(output)}`);
|
|
279
184
|
return "";
|
|
280
|
-
}
|
|
281
|
-
try {
|
|
282
|
-
rmSync(lockFile);
|
|
283
|
-
} catch {
|
|
284
|
-
/* ignore */
|
|
285
|
-
}
|
|
286
|
-
}
|
|
185
|
+
});
|
|
287
186
|
}
|
|
288
187
|
|
|
289
188
|
/**
|
|
@@ -302,11 +201,8 @@ export function splitHerdrPane(
|
|
|
302
201
|
if (!newPaneId) {
|
|
303
202
|
throw new Error(`Unexpected herdr pane split output: ${output.trim() || "(empty)"}`);
|
|
304
203
|
}
|
|
305
|
-
herdrPaneSources.set(newPaneId, fromPane);
|
|
306
204
|
if (name) renameHerdrPane(newPaneId, name);
|
|
307
|
-
herdrLog(
|
|
308
|
-
`[herdr split] mode=direct dir=${dir} from=${fromPane} new=${newPaneId} name=${JSON.stringify(name ?? "")}\n`,
|
|
309
|
-
);
|
|
205
|
+
herdrLog(`[split] mode=direct dir=${dir} from=${fromPane} new=${newPaneId} name=${JSON.stringify(name ?? "")}`);
|
|
310
206
|
return newPaneId;
|
|
311
207
|
}
|
|
312
208
|
|
|
@@ -320,7 +216,7 @@ export function renameHerdrPane(paneId: string, name: string): void {
|
|
|
320
216
|
const paneLabel = wsLabel ? `${wsLabel}[${name}]` : name;
|
|
321
217
|
herdrExecSilent(["pane", "rename", paneId, paneLabel]);
|
|
322
218
|
} catch (e) {
|
|
323
|
-
herdrLog(`[
|
|
219
|
+
herdrLog(`[rename pane] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
|
|
324
220
|
}
|
|
325
221
|
}
|
|
326
222
|
|
|
@@ -333,7 +229,7 @@ export function renameHerdrAgent(paneId: string, name: string): void {
|
|
|
333
229
|
try {
|
|
334
230
|
herdrExecSilent(["agent", "rename", paneId, name]);
|
|
335
231
|
} catch (e) {
|
|
336
|
-
herdrLog(`[
|
|
232
|
+
herdrLog(`[rename agent] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
|
|
337
233
|
}
|
|
338
234
|
}
|
|
339
235
|
|
|
@@ -376,7 +272,7 @@ export function renameHerdrTab(paneId: string, name: string): void {
|
|
|
376
272
|
}
|
|
377
273
|
}
|
|
378
274
|
} catch (e) {
|
|
379
|
-
herdrLog(`[
|
|
275
|
+
herdrLog(`[rename tab] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
|
|
380
276
|
}
|
|
381
277
|
}
|
|
382
278
|
|
|
@@ -390,7 +286,7 @@ export function renameHerdrWorkspace(title: string): void {
|
|
|
390
286
|
try {
|
|
391
287
|
herdrExecSilent(["workspace", "rename", AGENT_HERDR_WORKSPACE_ID, title]);
|
|
392
288
|
} catch (e) {
|
|
393
|
-
herdrLog(`[
|
|
289
|
+
herdrLog(`[rename workspace] title=${JSON.stringify(title)} failed: ${(e as Error).message}`);
|
|
394
290
|
}
|
|
395
291
|
}
|
|
396
292
|
|
|
@@ -435,38 +331,16 @@ export function readHerdrScreen(paneId: string, lines = 50, source: "visible" |
|
|
|
435
331
|
export function closeHerdrSurface(paneId: string): void {
|
|
436
332
|
herdrExecSilent(["pane", "close", paneId]);
|
|
437
333
|
|
|
438
|
-
//
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
parsed.panes.splice(idx, 1);
|
|
448
|
-
if (typeof parsed.pos === "number" && idx < parsed.pos) {
|
|
449
|
-
parsed.pos = Math.max(0, parsed.pos - 1);
|
|
450
|
-
}
|
|
451
|
-
if (parsed.panes.length === 0) {
|
|
452
|
-
rmSync(markerFile);
|
|
453
|
-
herdrLog(
|
|
454
|
-
`[herdr close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> [] pos=${beforePos} -> <marker-removed>\n`,
|
|
455
|
-
);
|
|
456
|
-
} else {
|
|
457
|
-
writeFileSync(markerFile, JSON.stringify(parsed));
|
|
458
|
-
herdrLog(
|
|
459
|
-
`[herdr close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> ${JSON.stringify(parsed.panes)} pos=${beforePos} -> ${parsed.pos}\n`,
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
} else {
|
|
463
|
-
herdrLog(`[herdr close] pane=${paneId} (not in marker state.panes)\n`);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
} catch {
|
|
467
|
-
herdrLog(`[herdr close] pane=${paneId} (no marker, nothing to clean)\n`);
|
|
334
|
+
// 从 BFS 状态移除已关闭的 subagent,避免僵尸 ID 累积
|
|
335
|
+
const state = new BfsSplitStateManager(herdrMarkerPath());
|
|
336
|
+
const beforePanes = state.panes();
|
|
337
|
+
state.remove(paneId);
|
|
338
|
+
const afterPanes = state.panes();
|
|
339
|
+
if (beforePanes.length !== afterPanes.length) {
|
|
340
|
+
herdrLog(`[close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> ${JSON.stringify(afterPanes)}`);
|
|
341
|
+
} else {
|
|
342
|
+
herdrLog(`[close] pane=${paneId} (not in marker state.panes)`);
|
|
468
343
|
}
|
|
469
|
-
herdrPaneSources.delete(paneId);
|
|
470
344
|
}
|
|
471
345
|
|
|
472
346
|
// ── 辅助:从 pane id 解析 workspace id ──
|
|
@@ -530,4 +404,4 @@ export const ops: BackendOps = {
|
|
|
530
404
|
rename(surface: string, name: string): void {
|
|
531
405
|
renameHerdrPane(surface, name);
|
|
532
406
|
},
|
|
533
|
-
};
|
|
407
|
+
};
|