pi-terminal-mux 0.4.1 → 0.5.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
@@ -88,10 +88,14 @@ export PI_SUBAGENT_HERDR_MODE=tab
88
88
  | `sendEscape(surface)` | Send one ESC keypress |
89
89
  | `readScreen(surface, lines?, options?)` / `readScreenAsync` | Read the last N screen lines. `options.source` (herdr-only) forwards a herdr read source such as `"recent_unwrapped"`; other backends ignore it |
90
90
  | `closeSurface(surface)` | Close the surface |
91
- | `renameSurface(surface, name)` / `renameCurrentTab(title)` / `renameAgent(surface, name)` / `renameWorkspace(title)` | Naming, degrading per backend capability |
91
+ | `renameSurface(surface, name)` / `renameAgent(surface, name)` | Rename a known surface or agent label |
92
+ | `getRenameCapability(operation, backend?, env?)` | Report the actual rename target or an explicit `unsupported` / `disabled` capability without executing a command |
93
+ | `renameCurrentTab(title)` / `renameWorkspace(title)` | Rename and return a discriminated `renamed` / `unsupported` / `disabled` / `failed` result |
92
94
  | `pollForExit(surface, signal, opts)` | Wait for the process in a surface to exit: `.exit` sidecar file first, then a screen sentinel (`__SUBAGENT_DONE_<code>__`); headless uses child process exit |
93
95
  | `getLastSplitSource()` / `clearLastSplitSource()` | Source pane of the most recent split (for UI display) |
94
96
 
97
+ Rename targets differ by backend: muxy/zellij tab rename targets a pane; tmux targets a window/session when its opt-in variables are enabled; WezTerm workspace rename targets the window; cmux and Herdr provide native workspace rename; Otty and Orca have no workspace rename. Headless reports `unsupported` instead of silently succeeding.
98
+
95
99
  ### Detection and utilities
96
100
 
97
101
  `getMuxBackend()`, `isMuxAvailable()`, `isHeadlessMode()`, `muxSetupHint()`, `getAgentPaneId(backend?)`, `backendAgentPaneEnvVar(backend)`, `shellEscape()`, `isFishShell()`, `exitStatusVar()`, plus zellij placement planning (`selectZellijPlacement` etc.) and cmux/otty JSON parsing helpers — all pure and unit-testable.
@@ -134,3 +138,14 @@ These are opt-in capabilities — existing Bash callers and `pi-interactive-suba
134
138
  ## License
135
139
 
136
140
  MIT
141
+
142
+
143
+ ## Scoped naming
144
+
145
+ `createSurfaceRenameContext(surface)` describes the terminal target a launcher can grant to a child. Pass its JSON value in `PI_TERMINAL_RENAME_CONTEXT`, replacing any inherited value on **every launch and resume**. This protocol is owned by terminal-mux, not by a naming or subagent extension.
146
+
147
+ `resolveTerminalRenameTargets({ tab, workspace })` returns explicit target IDs and `surface`/`shared` scope, or individual skipped/failed results. `renameTerminalTarget(reference, title)` executes against that captured identity. Callers decide when to rename and which targets to request; the library does not generate titles or change Pi sessions.
148
+
149
+ A restricted child never renames a workspace. cmux surfaces, muxy/zellij panes, and Herdr panes or explicitly created Herdr tabs can be granted. tmux/WezTerm/Otty/Orca split surfaces do not prove exclusive ownership of their window/tab: naming is skipped rather than expanded to the shared parent. Ordinary sessions require their own target IDs; missing IDs never fall back to focus or the first tab. Existing backend rename opt-ins still apply to ordinary sessions.
150
+
151
+ Invalid JSON or an unknown protocol version is an error, not unrestricted access. Legacy child identity variables without this protocol restrict terminal naming until the launcher supplies ownership. This is a cooperation contract for trusted local processes, not a security sandbox. WezTerm/Otty split creation does not rename a shared tab.
package/README.zh-CN.md CHANGED
@@ -87,10 +87,14 @@ export PI_SUBAGENT_HERDR_MODE=tab
87
87
  | `sendEscape(surface)` | 发送一次 ESC |
88
88
  | `readScreen(surface, lines?, options?)` / `readScreenAsync` | 读取屏幕尾部 N 行;`options.source`(仅 herdr)透传 herdr 读屏来源(如 `"recent_unwrapped"`),其他后端忽略 |
89
89
  | `closeSurface(surface)` | 关闭 surface |
90
- | `renameSurface(surface, name)` / `renameCurrentTab(title)` / `renameAgent(surface, name)` / `renameWorkspace(title)` | 命名(按后端能力降级或跳过) |
90
+ | `renameSurface(surface, name)` / `renameAgent(surface, name)` | 重命名已知 surface 或 agent 标签 |
91
+ | `getRenameCapability(operation, backend?, env?)` | 不执行命令,返回实际重命名目标,或明确的 `unsupported` / `disabled` 能力结果 |
92
+ | `renameCurrentTab(title)` / `renameWorkspace(title)` | 执行重命名并返回可判别的 `renamed` / `unsupported` / `disabled` / `failed` 结果 |
91
93
  | `pollForExit(surface, signal, opts)` | 等待 surface 内进程退出:优先 `.exit` sidecar 文件,其次屏幕 sentinel(`__SUBAGENT_DONE_<code>__`),headless 走子进程 exit |
92
94
  | `getLastSplitSource()` / `clearLastSplitSource()` | 最近一次分屏的来源 pane(用于 UI 展示) |
93
95
 
96
+ 各后端的实际目标不同:muxy/zellij 的 tab 重命名作用于 pane;tmux 在开启对应变量后作用于 window/session;WezTerm 的 workspace 重命名作用于 window;cmux 和 Herdr 提供原生 workspace 重命名;Otty、Orca 没有 workspace 重命名。Headless 会明确返回 `unsupported`,不再静默成功。
97
+
94
98
  ### 探测与工具
95
99
 
96
100
  `getMuxBackend()`、`isMuxAvailable()`、`isHeadlessMode()`、`muxSetupHint()`、`getAgentPaneId(backend?)`、`backendAgentPaneEnvVar(backend)`、`shellEscape()`、`isFishShell()`、`exitStatusVar()`,以及 zellij 放置规划(`selectZellijPlacement` 等)与 cmux/otty JSON 解析等纯函数,均可直接引用做单元测试。
@@ -133,3 +137,14 @@ export PI_SUBAGENT_HERDR_MODE=tab
133
137
  ## License
134
138
 
135
139
  MIT
140
+
141
+
142
+ ## 按归属改名
143
+
144
+ `createSurfaceRenameContext(surface)` 描述启动方可授予子进程的终端改名目标。把返回值序列化为 JSON 放入 `PI_TERMINAL_RENAME_CONTEXT`,**每次启动和恢复都替换继承值**。协议属于 terminal-mux,不属于命名或子代理插件。
145
+
146
+ `resolveTerminalRenameTargets({ tab, workspace })` 返回明确的目标 ID、`surface`/`shared` 范围,或逐目标跳过/失败结果。`renameTerminalTarget(reference, title)` 对捕获的身份执行改名。调用方决定何时改、改哪些目标;库不生成标题,也不修改 Pi session。
147
+
148
+ 受限子进程不能改 workspace。cmux surface、muxy/zellij pane、Herdr pane 或明确新建的 Herdr tab 可以授予。tmux/WezTerm/Otty/Orca 的分屏不能证明独占 window/tab,改名会跳过,不扩大到共享父目标。普通会话必须能确定自身目标 ID;缺失时不退回当前焦点或第一个 tab。普通会话仍遵守各后端原有的改名开关。
149
+
150
+ 协议 JSON 损坏或版本未知会报错,不能退回无限制范围。只有旧子代理身份标志、没有归属协议时,终端改名受限,直到启动方提供归属。此协议用于可信本地进程协作,不是安全沙箱。WezTerm/Otty 创建分屏时不会重命名共享 tab。
package/locales/mux.json CHANGED
@@ -50,5 +50,17 @@
50
50
  "error.invalidHerdrMode": {
51
51
  "zh-CN": "不支持的 Herdr surface 模式:{value}。可选值:split、tab。",
52
52
  "en-US": "Unsupported Herdr surface mode: {value}. Choose split or tab."
53
+ },
54
+ "error.renameIncomplete": {
55
+ "zh-CN": "{backend} 未完成终端改名。",
56
+ "en-US": "{backend} did not complete terminal renaming."
57
+ },
58
+ "rename.invalidContext": {
59
+ "zh-CN": "终端改名归属信息无效;未修改终端名称。",
60
+ "en-US": "Invalid terminal rename ownership context; terminal names were not changed."
61
+ },
62
+ "rename.missingId": {
63
+ "zh-CN": "无法确定终端改名目标 ID。",
64
+ "en-US": "Cannot determine the terminal rename target ID."
53
65
  }
54
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-terminal-mux",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Terminal multiplexer abstraction for pi extensions — unified surface API across muxy, cmux, tmux, zellij, wezterm, herdr, otty and orca, with headless fallback",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -20,6 +20,10 @@ import { createBackendLogger, withFileLock, BfsSplitStateManager, hasCommand } f
20
20
  // ── 日志(统一格式,写入 /tmp/pi-mux-herdr.log) ──
21
21
  const herdrLog = createBackendLogger("herdr", "/tmp/pi-mux-herdr.log");
22
22
  const HERDR_TAB_CLOSE_COMMAND = ["tab", "close"] as const;
23
+ const HERDR_TAB_LIST_COMMAND = ["tab", "list"] as const;
24
+ const HERDR_TAB_RENAME_COMMAND = ["tab", "rename"] as const;
25
+ const HERDR_WORKSPACE_RENAME_COMMAND = ["workspace", "rename"] as const;
26
+ const HERDR_WORKSPACE_FLAG = "--workspace";
23
27
  const HERDR_PANE_SPLIT_COMMAND = ["pane", "split"] as const;
24
28
  const HERDR_PANE_CLOSE_COMMAND = ["pane", "close"] as const;
25
29
  const HERDR_SPLIT_DIRECTION_FLAG = "--direction";
@@ -155,6 +159,11 @@ function extractCreatedHerdrTab(json: unknown): CreatedHerdrTab | null {
155
159
  /** 记录由本进程创建的 tab surface,使 rename / close 操作作用于整个 tab。 */
156
160
  const createdHerdrTabIds = new Map<string, string>();
157
161
 
162
+ /** 仅返回本进程实际创建的独占 tab,供启动方声明改名归属。 */
163
+ export function getCreatedHerdrTabId(surface: string): string | undefined {
164
+ return createdHerdrTabIds.get(surface);
165
+ }
166
+
158
167
  // ── 对外 API:createSurface 系列 ──
159
168
 
160
169
  /** 使用原有 BFS 策略创建 subagent 分屏。 */
@@ -358,24 +367,28 @@ function herdrSurfaceLabel(name: string): string {
358
367
  * 用 herdr CLI 重命名 pane 对应的 tab。
359
368
  * tab label 格式: workspace_label[name]
360
369
  */
361
- export function renameHerdrTab(paneId: string, name: string): void {
370
+ export function renameHerdrTab(paneId: string, name: string): boolean {
362
371
  const ws = parseWorkspaceIdFromPaneId(paneId);
363
- if (!ws) return;
372
+ if (!ws) return false;
364
373
  try {
365
374
  const tabLabel = herdrSurfaceLabel(name);
366
- const tabsJson = herdrExec(["tab", "list", "--workspace", ws]);
375
+ if (paneId === AGENT_HERDR_PANE_ID && AGENT_HERDR_TAB_ID) {
376
+ herdrExecSilent([...HERDR_TAB_RENAME_COMMAND, AGENT_HERDR_TAB_ID, tabLabel]);
377
+ return true;
378
+ }
379
+
380
+ const tabsJson = herdrExec([...HERDR_TAB_LIST_COMMAND, HERDR_WORKSPACE_FLAG, ws]);
367
381
  const parsed = parseHerdrJson(tabsJson);
368
- if (!parsed || typeof parsed !== "object") return;
382
+ if (!parsed || typeof parsed !== "object") return false;
369
383
  const result = (parsed as Record<string, unknown>).result as Record<string, unknown> | undefined;
370
384
  const tabs = (result?.tabs as Array<Record<string, unknown>>) ?? [];
371
- if (tabs.length > 0) {
372
- const firstTab = tabs[0];
373
- if (firstTab && typeof firstTab.tab_id === "string") {
374
- herdrExecSilent(["tab", "rename", firstTab.tab_id, tabLabel]);
375
- }
376
- }
385
+ const firstTab = tabs[0];
386
+ if (!firstTab || typeof firstTab.tab_id !== "string") return false;
387
+ herdrExecSilent([...HERDR_TAB_RENAME_COMMAND, firstTab.tab_id, tabLabel]);
388
+ return true;
377
389
  } catch (e) {
378
390
  herdrLog(`[rename tab] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
391
+ return false;
379
392
  }
380
393
  }
381
394
 
@@ -383,13 +396,15 @@ export function renameHerdrTab(paneId: string, name: string): void {
383
396
  * 重命名 workspace。herdr 中 workspace rename 命令是 `herdr workspace rename <id> <label>`。
384
397
  * 仅当环境变量 PI_SUBAGENT_RENAME_HERDR_WORKSPACE=1 时启用(保守策略,避免影响用户命名)。
385
398
  */
386
- export function renameHerdrWorkspace(title: string): void {
387
- if (process.env.PI_SUBAGENT_RENAME_HERDR_WORKSPACE !== "1") return;
388
- if (!AGENT_HERDR_WORKSPACE_ID) return;
399
+ export function renameHerdrWorkspace(title: string): boolean {
400
+ if (process.env.PI_SUBAGENT_RENAME_HERDR_WORKSPACE !== "1") return false;
401
+ if (!AGENT_HERDR_WORKSPACE_ID) return false;
389
402
  try {
390
- herdrExecSilent(["workspace", "rename", AGENT_HERDR_WORKSPACE_ID, title]);
403
+ herdrExecSilent([...HERDR_WORKSPACE_RENAME_COMMAND, AGENT_HERDR_WORKSPACE_ID, title]);
404
+ return true;
391
405
  } catch (e) {
392
406
  herdrLog(`[rename workspace] title=${JSON.stringify(title)} failed: ${(e as Error).message}`);
407
+ return false;
393
408
  }
394
409
  }
395
410
 
@@ -211,8 +211,8 @@ function orcaExec(args: string[]): string {
211
211
  return result.stdout;
212
212
  }
213
213
 
214
- /** 调用 `orca` 命令,失败只记 log 不抛错。用于 send/rename/close 这类 best-effort 操作。 */
215
- function orcaExecSilent(args: string[]): void {
214
+ /** 调用 `orca` 命令,失败只记 log 不抛错,并返回执行是否成功。 */
215
+ function orcaExecSilent(args: string[]): boolean {
216
216
  const cmdline = `orca ${args
217
217
  .map((a) => (a.includes(" ") || a.includes('"') ? JSON.stringify(a) : a))
218
218
  .join(" ")}`;
@@ -228,7 +228,9 @@ function orcaExecSilent(args: string[]): void {
228
228
  (result.stderr ?? "").trim().slice(0, 200),
229
229
  )}`,
230
230
  );
231
+ return false;
231
232
  }
233
+ return true;
232
234
  }
233
235
 
234
236
  // ── 对外 API ──
@@ -424,8 +426,8 @@ export function closeOrcaSurface(handle: string): void {
424
426
  * 注意:rename 作用于 tab 标题,split pane 与源 pane 共享 tab ——
425
427
  * 调用方需确认 target 是独立 tab(create() 产物或 agent 自己的 terminal)。
426
428
  */
427
- export function renameOrcaTerminal(handle: string, name: string): void {
428
- orcaExecSilent(["terminal", "rename", "--terminal", handle, "--title", name]);
429
+ export function renameOrcaTerminal(handle: string, name: string): boolean {
430
+ return orcaExecSilent(["terminal", "rename", "--terminal", handle, "--title", name]);
429
431
  }
430
432
 
431
433
  // ── BackendOps 适配器(薄包装以上原生函数,行为语义见各函数注释) ──
@@ -194,7 +194,7 @@ function ottyExec(args: string[]): string {
194
194
  * 调用 `otty` 命令,丢弃 stdout。用于 sendCommand / sendKeys / closePane 这类
195
195
  * 无输出的命令。
196
196
  */
197
- function ottyExecSilent(args: string[]): void {
197
+ function ottyExecSilent(args: string[]): boolean {
198
198
  const cmdline = `otty ${args
199
199
  .map((a) => (a.includes(" ") || a.includes('"') ? JSON.stringify(a) : a))
200
200
  .join(" ")}`;
@@ -209,7 +209,9 @@ function ottyExecSilent(args: string[]): void {
209
209
  (result.stderr ?? "").trim().slice(0, 200),
210
210
  )}`,
211
211
  );
212
+ return false;
212
213
  }
214
+ return true;
213
215
  }
214
216
 
215
217
  // ── Pane 数据结构 ──
@@ -368,7 +370,7 @@ export function createOttySurface(name: string): string {
368
370
  return "";
369
371
  }
370
372
  state.add(newId);
371
- renameOttyTab(newId, name);
373
+ // 新 pane 不代表独占 tab,保留创建时的 pane 标题。
372
374
  ottyLog(`[create] mode=first dir=right from=${agentId} new=${newId} name=${JSON.stringify(name)}`);
373
375
  return newId;
374
376
  }
@@ -436,7 +438,7 @@ export function createOttySurface(name: string): string {
436
438
  // split 源是 agent pane(不在状态机里),只需 add。
437
439
  if (!recovered) state.advance();
438
440
  state.add(newId);
439
- renameOttyTab(newId, name);
441
+ // 新 pane 不代表独占 tab,保留创建时的 pane 标题。
440
442
  ottyLog(`[create] mode=next dir=${direction} from=${target} new=${newId} name=${JSON.stringify(name)}`);
441
443
  return newId;
442
444
  });
@@ -565,16 +567,21 @@ export function closeOttySurface(paneId: string): void {
565
567
  * 重命名 pane 对应的 tab。
566
568
  * Otty 没有"pane -> tab id"的直接命令,所以用 `panes --json` 反查 tab_id。
567
569
  */
568
- export function renameOttyTab(paneId: string, name: string): void {
570
+ export function renameOttyTab(paneId: string, name: string): boolean {
569
571
  const tabId = getTabIdForPane(paneId);
570
572
  if (!tabId) {
571
573
  ottyLog(`[rename] pane=${paneId} no tab id found`);
572
- return;
574
+ return false;
573
575
  }
574
576
  try {
575
- ottyExecSilent(["tab", "rename", "--tab", tabId, name]);
577
+ if (!ottyExecSilent(["tab", "rename", "--tab", tabId, name])) {
578
+ ottyLog(`[rename] pane=${paneId} tab=${tabId} name=${JSON.stringify(name)} failed`);
579
+ return false;
580
+ }
581
+ return true;
576
582
  } catch (e) {
577
583
  ottyLog(`[rename] pane=${paneId} tab=${tabId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
584
+ return false;
578
585
  }
579
586
  }
580
587
 
@@ -68,13 +68,7 @@ export const ops: BackendOps = {
68
68
  throw new Error(`Unexpected wezterm split-pane output: ${rawId || "(empty)"}`);
69
69
  }
70
70
  const paneId = rawId;
71
- try {
72
- execFileSync("wezterm", ["cli", "set-tab-title", "--pane-id", paneId, name], {
73
- encoding: "utf8",
74
- });
75
- } catch {
76
- // Optional — tab title is cosmetic.
77
- }
71
+ // 分屏与父 pane 共享 tab,不能用子任务名称覆盖共享标题。
78
72
  if (options?.activate) {
79
73
  execFileSync("wezterm", weztermActivateArgs(paneId), { encoding: "utf8" });
80
74
  }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * - sendCommand / sendLongCommand / sendEscape
11
11
  * - readScreen / readScreenAsync
12
12
  * - closeSurface
13
- * - renameCurrentTab / renameAgent / renameWorkspace
13
+ * - renameCurrentTab / renameAgent / renameWorkspace / getRenameCapability
14
14
  * - pollForExit 等待 surface 内进程退出(.exit sidecar / sentinel)
15
15
  *
16
16
  * 后端探测:
package/src/mux.ts CHANGED
@@ -51,6 +51,7 @@ export {
51
51
  closeSurface,
52
52
  renameSurface,
53
53
  renameAgent,
54
+ getRenameCapability,
54
55
  renameCurrentTab,
55
56
  renameWorkspace,
56
57
  pollForExit,
@@ -62,6 +63,11 @@ export type {
62
63
  CreateSurfaceSplitOptions,
63
64
  SendLongCommandOptions,
64
65
  ReadScreenOptions,
66
+ RenameOperation,
67
+ RenameTarget,
68
+ RenameBackend,
69
+ RenameCapability,
70
+ RenameResult,
65
71
  } from "./surface.ts";
66
72
 
67
73
  // ── Cmux 公开解析函数 ──
@@ -89,3 +95,19 @@ export {
89
95
  renameHerdrTab,
90
96
  renameHerdrWorkspace,
91
97
  } from "./backends/herdr.ts";
98
+
99
+ // ── 精确目标和跨进程改名归属 ──
100
+ export {
101
+ TERMINAL_RENAME_CONTEXT_ENV,
102
+ createSurfaceRenameContext,
103
+ readSurfaceRenameContext,
104
+ resolveTerminalRenameTargets,
105
+ renameTerminalTarget,
106
+ } from "./rename.ts";
107
+ export type {
108
+ SurfaceRenameContext,
109
+ TerminalRenameTarget,
110
+ TerminalRenameOutcome,
111
+ ResolveRenameOptions,
112
+ RenameCommandRunner,
113
+ } from "./rename.ts";
package/src/rename.ts ADDED
@@ -0,0 +1,212 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { getMuxBackend, type MuxBackend } from "./detection.ts";
3
+ import { getRenameCapability, type RenameOperation, type RenameTarget } from "./surface.ts";
4
+ import { getCreatedHerdrTabId } from "./backends/herdr.ts";
5
+ import { AGENT_OTTY_PANE_ID, getTabIdForPane } from "./backends/otty.ts";
6
+ import { renameOrcaTerminal } from "./backends/orca.ts";
7
+ import { i18n } from "./i18n.ts";
8
+
9
+ export const TERMINAL_RENAME_CONTEXT_ENV = "PI_TERMINAL_RENAME_CONTEXT";
10
+ const CONTEXT_VERSION = 1;
11
+ const COMMAND_TIMEOUT_MS = 5_000;
12
+ const RENAME_COMMANDS = {
13
+ cmuxTab: ["rename-tab", "--surface"],
14
+ cmuxWorkspace: ["workspace-action", "--workspace"],
15
+ cmuxWorkspaceAction: ["--action", "rename", "--title"],
16
+ muxy: ["rename-pane", "--pane"],
17
+ tmuxTab: ["rename-window", "-t"],
18
+ tmuxWorkspace: ["rename-session", "-t"],
19
+ tmuxLookup: ["display-message", "-p", "-t"],
20
+ tmuxWindowId: "#{window_id}",
21
+ tmuxSessionId: "#{session_id}",
22
+ zellij: ["action", "rename-pane"],
23
+ paneId: "--pane-id",
24
+ weztermTab: ["cli", "set-tab-title", "--pane-id"],
25
+ weztermWorkspace: ["cli", "set-window-title", "--pane-id"],
26
+ herdr: "rename",
27
+ otty: ["tab", "rename", "--tab"],
28
+ } as const;
29
+ const BACKENDS = ["cmux", "muxy", "tmux", "zellij", "wezterm", "herdr", "otty", "orca"] as const;
30
+
31
+ export interface TerminalRenameTarget {
32
+ backend: MuxBackend;
33
+ operation: RenameOperation;
34
+ target: RenameTarget;
35
+ id: string;
36
+ scope: "surface" | "shared";
37
+ }
38
+
39
+ /** 启动方授予的单个 surface 改名范围;不是操作系统安全边界。 */
40
+ export interface SurfaceRenameContext {
41
+ version: 1;
42
+ backend: MuxBackend | null;
43
+ surface: string;
44
+ ownedTarget: { target: "pane" | "tab" | "terminal"; id: string } | null;
45
+ }
46
+
47
+ export type TerminalRenameOutcome =
48
+ | { status: "ready" | "renamed"; reference: TerminalRenameTarget }
49
+ | { status: "skipped"; operation: RenameOperation; reason: "unsupported" | "disabled" | "shared" | "unverified" | "missing-id"; setting?: string }
50
+ | { status: "failed"; operation: RenameOperation; error: string };
51
+
52
+ /** 只把可确认独占的 surface 授给子进程,绝不把 pane 扩大为共享窗口。 */
53
+ export function createSurfaceRenameContext(surface: string, backend = getMuxBackend()): SurfaceRenameContext {
54
+ let ownedTarget: SurfaceRenameContext["ownedTarget"] = null;
55
+ if (surface.startsWith("headless:")) backend = null;
56
+ if (surface) {
57
+ if (backend === "cmux") ownedTarget = { target: "tab", id: surface };
58
+ if (backend === "muxy" || backend === "zellij") ownedTarget = { target: "pane", id: surface };
59
+ if (backend === "herdr") {
60
+ const tabId = getCreatedHerdrTabId(surface);
61
+ ownedTarget = tabId ? { target: "tab", id: tabId } : { target: "pane", id: surface };
62
+ }
63
+ }
64
+ return { version: CONTEXT_VERSION, backend, surface, ownedTarget };
65
+ }
66
+
67
+ /** 校验跨进程协议;损坏或未知版本不退回主会话权限。 */
68
+ export function readSurfaceRenameContext(env: NodeJS.ProcessEnv = process.env): SurfaceRenameContext | undefined {
69
+ const raw = env[TERMINAL_RENAME_CONTEXT_ENV];
70
+ if (raw === undefined) {
71
+ // 旧启动方没有归属信息时,只读其身份标志以收紧范围,不能猜测独占 tab。
72
+ if (env.PI_SUBAGENT_ID || env.PI_SUBAGENT_SURFACE) {
73
+ return { version: CONTEXT_VERSION, backend: null, surface: "", ownedTarget: null };
74
+ }
75
+ return undefined;
76
+ }
77
+ let value: unknown;
78
+ try { value = JSON.parse(raw); } catch { throw new Error(i18n.t("rename.invalidContext")); }
79
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(i18n.t("rename.invalidContext"));
80
+ const context = value as Record<string, unknown>;
81
+ const validBackend = context.backend === null || BACKENDS.some((backend) => backend === context.backend);
82
+ if (context.version !== CONTEXT_VERSION || !validBackend || typeof context.surface !== "string" ||
83
+ Object.keys(context).some((key) => !["version", "backend", "surface", "ownedTarget"].includes(key))) {
84
+ throw new Error(i18n.t("rename.invalidContext"));
85
+ }
86
+ if (context.ownedTarget !== null) {
87
+ if (!context.ownedTarget || typeof context.ownedTarget !== "object" || Array.isArray(context.ownedTarget)) {
88
+ throw new Error(i18n.t("rename.invalidContext"));
89
+ }
90
+ const owned = context.ownedTarget as Record<string, unknown>;
91
+ const targetByBackend: Partial<Record<MuxBackend, readonly string[]>> = {
92
+ cmux: ["tab"], muxy: ["pane"], zellij: ["pane"], herdr: ["pane", "tab"],
93
+ };
94
+ if (!context.surface || typeof owned.id !== "string" || !owned.id.trim() ||
95
+ Object.keys(owned).some((key) => !["target", "id"].includes(key)) ||
96
+ !targetByBackend[context.backend as MuxBackend]?.includes(owned.target as string) ||
97
+ (!(context.backend === "herdr" && owned.target === "tab") && owned.id !== context.surface)) {
98
+ throw new Error(i18n.t("rename.invalidContext"));
99
+ }
100
+ }
101
+ return context as unknown as SurfaceRenameContext;
102
+ }
103
+
104
+ export interface ResolveRenameOptions {
105
+ tab: boolean;
106
+ workspace: boolean;
107
+ backend?: MuxBackend | null;
108
+ env?: NodeJS.ProcessEnv;
109
+ }
110
+
111
+ /** 返回当前进程的明确目标 ID,不以当前焦点或第一个 tab 代替未知身份。 */
112
+ function currentTargetId(reference: { backend: MuxBackend; operation: RenameOperation; env: NodeJS.ProcessEnv }, query: RenameIdQuery): string | undefined {
113
+ const { backend, operation, env } = reference;
114
+ const surfaceKeys: Record<MuxBackend, string> = {
115
+ cmux: "CMUX_SURFACE_ID", muxy: "MUXY_PANE_ID", tmux: "TMUX_PANE", zellij: "ZELLIJ_PANE_ID",
116
+ wezterm: "WEZTERM_PANE", herdr: "HERDR_TAB_ID", otty: "OTTY_PANE_ID", orca: "ORCA_TERMINAL_HANDLE",
117
+ };
118
+ if (backend === "tmux") {
119
+ const pane = env.TMUX_PANE;
120
+ return pane ? query("tmux", [...RENAME_COMMANDS.tmuxLookup, pane,
121
+ operation === "tab" ? RENAME_COMMANDS.tmuxWindowId : RENAME_COMMANDS.tmuxSessionId]).trim() : undefined;
122
+ }
123
+ if (backend === "otty") {
124
+ const pane = env.OTTY_PANE_ID ?? AGENT_OTTY_PANE_ID;
125
+ return pane ? getTabIdForPane(pane) ?? undefined : undefined;
126
+ }
127
+ if (operation === "workspace" && backend === "cmux") return env.CMUX_WORKSPACE_ID;
128
+ if (operation === "workspace" && backend === "herdr") return env.HERDR_WORKSPACE_ID;
129
+ return env[surfaceKeys[backend]];
130
+ }
131
+
132
+ export type RenameIdQuery = (command: string, args: string[]) => string;
133
+
134
+ /** 查询父 window/session 的明确身份,限制外部命令等待时间。 */
135
+ function queryRenameId(command: string, args: string[]): string {
136
+ return execFileSync(command, args, { encoding: "utf8", timeout: COMMAND_TIMEOUT_MS });
137
+ }
138
+
139
+ /** 解析一次批量改名的目标;各目标的失败、关闭或跳过分别保留。 */
140
+ export function resolveTerminalRenameTargets(options: ResolveRenameOptions, query: RenameIdQuery = queryRenameId): TerminalRenameOutcome[] {
141
+ const env = options.env ?? process.env;
142
+ const context = readSurfaceRenameContext(env);
143
+ const backend = options.backend === undefined ? getMuxBackend() : options.backend;
144
+ const outcomes: TerminalRenameOutcome[] = [];
145
+ for (const operation of ["workspace", "tab"] as const) {
146
+ if (!options[operation]) continue;
147
+ if (context) {
148
+ if (operation === "workspace") {
149
+ outcomes.push({ status: "skipped", operation, reason: "shared" });
150
+ } else if (!context.ownedTarget || !backend || context.backend !== backend) {
151
+ outcomes.push({ status: "skipped", operation, reason: "unverified" });
152
+ } else {
153
+ outcomes.push({ status: "ready", reference: { backend, operation, ...context.ownedTarget, scope: "surface" } });
154
+ }
155
+ continue;
156
+ }
157
+ const capability = getRenameCapability(operation, backend, env);
158
+ if (capability.status !== "supported") {
159
+ outcomes.push({ status: "skipped", operation, reason: capability.status,
160
+ ...(capability.status === "disabled" ? { setting: capability.setting } : {}) });
161
+ continue;
162
+ }
163
+ try {
164
+ const id = currentTargetId({ backend: capability.backend, operation, env }, query);
165
+ outcomes.push(id?.trim() ? { status: "ready", reference: {
166
+ backend: capability.backend, operation, target: capability.backend === "orca" ? "tab" : capability.target, id,
167
+ scope: operation === "workspace" || ["tmux", "wezterm", "herdr", "otty", "orca"].includes(capability.backend) ? "shared" : "surface",
168
+ } } : { status: "skipped", operation, reason: "missing-id" });
169
+ } catch (error) {
170
+ outcomes.push({ status: "failed", operation, error: error instanceof Error ? error.message : String(error) });
171
+ }
172
+ }
173
+ return outcomes;
174
+ }
175
+
176
+ export type RenameCommandRunner = (command: string, args: string[]) => void;
177
+
178
+ /** 改名命令设置超时,异常留给调用方转成逐目标结果。 */
179
+ function runRenameCommand(command: string, args: string[]): void {
180
+ execFileSync(command, args, { encoding: "utf8", timeout: COMMAND_TIMEOUT_MS });
181
+ }
182
+
183
+ /** 用解析时捕获的身份执行改名,不再查询当前焦点。 */
184
+ export function renameTerminalTarget(
185
+ reference: TerminalRenameTarget,
186
+ title: string,
187
+ dependencies: { run: RenameCommandRunner; renameOrca: typeof renameOrcaTerminal } = { run: runRenameCommand, renameOrca: renameOrcaTerminal },
188
+ ): TerminalRenameOutcome {
189
+ const { backend, operation, target, id } = reference;
190
+ const { run, renameOrca } = dependencies;
191
+ try {
192
+ if (!id.trim()) throw new Error(i18n.t("rename.missingId"));
193
+ switch (backend) {
194
+ case "cmux":
195
+ run("cmux", operation === "tab" ? [...RENAME_COMMANDS.cmuxTab, id, title]
196
+ : [...RENAME_COMMANDS.cmuxWorkspace, id, ...RENAME_COMMANDS.cmuxWorkspaceAction, title]);
197
+ break;
198
+ case "muxy": run("muxy", [...RENAME_COMMANDS.muxy, id, title]); break;
199
+ case "tmux": run("tmux", [...(operation === "tab" ? RENAME_COMMANDS.tmuxTab : RENAME_COMMANDS.tmuxWorkspace), id, title]); break;
200
+ case "zellij": run("zellij", [...RENAME_COMMANDS.zellij, title, RENAME_COMMANDS.paneId, id]); break;
201
+ case "wezterm": run("wezterm", [...(operation === "tab" ? RENAME_COMMANDS.weztermTab : RENAME_COMMANDS.weztermWorkspace), id, title]); break;
202
+ case "herdr": run("herdr", [target, RENAME_COMMANDS.herdr, id, title]); break;
203
+ case "otty": run("otty", [...RENAME_COMMANDS.otty, id, title]); break;
204
+ case "orca":
205
+ if (!renameOrca(id, title)) throw new Error(i18n.t("error.renameIncomplete", { backend }));
206
+ break;
207
+ }
208
+ return { status: "renamed", reference };
209
+ } catch (error) {
210
+ return { status: "failed", operation, error: error instanceof Error ? error.message : String(error) };
211
+ }
212
+ }
package/src/surface.ts CHANGED
@@ -55,6 +55,10 @@ import { renameOrcaTerminal } from "./backends/orca.ts";
55
55
  const execFileAsync = promisify(execFile);
56
56
  const ORCA_BACKEND: MuxBackend = "orca";
57
57
  const HERDR_BACKEND: MuxBackend = "herdr";
58
+ const TMUX_WINDOW_RENAME_SETTING = "PI_SUBAGENT_RENAME_TMUX_WINDOW";
59
+ const TMUX_SESSION_RENAME_SETTING = "PI_SUBAGENT_RENAME_TMUX_SESSION";
60
+ const HERDR_WORKSPACE_RENAME_SETTING = "PI_SUBAGENT_RENAME_HERDR_WORKSPACE";
61
+ const ENABLED_SETTING_VALUE = "1";
58
62
 
59
63
  // ── 全键注册表 ──
60
64
 
@@ -406,139 +410,163 @@ export function renameAgent(surface: string, name: string): void {
406
410
  }
407
411
  }
408
412
 
409
- /**
410
- * 重命名当前 tab / window。
411
- */
412
- export function renameCurrentTab(title: string): void {
413
- if (isHeadlessMode()) return;
414
-
415
- const backend = requireMuxBackend();
416
-
417
- if (backend === "cmux") {
418
- const surfaceId = process.env.CMUX_SURFACE_ID;
419
- if (!surfaceId) throw new Error("CMUX_SURFACE_ID not set");
420
- execSync(`cmux rename-tab --surface ${shellEscape(surfaceId)} ${shellEscape(title)}`, {
421
- encoding: "utf8",
422
- });
423
- return;
424
- }
425
-
426
- if (backend === "muxy") {
427
- const paneId = AGENT_MUXY_PANE_ID;
428
- if (!paneId) throw new Error("MUXY_PANE_ID not set");
429
- execFileSync("muxy", ["rename-pane", "--pane", paneId, title], { encoding: "utf8" });
430
- return;
431
- }
432
-
433
- if (backend === "tmux") {
434
- if (process.env.PI_SUBAGENT_RENAME_TMUX_WINDOW !== "1") {
435
- return;
413
+ export type RenameOperation = "tab" | "workspace";
414
+ export type RenameTarget = "tab" | "window" | "pane" | "workspace" | "session" | "terminal";
415
+ export type RenameBackend = MuxBackend | "headless";
416
+
417
+ export type RenameCapability =
418
+ | { status: "supported"; backend: MuxBackend; operation: RenameOperation; target: RenameTarget }
419
+ | { status: "unsupported"; backend: RenameBackend; operation: RenameOperation }
420
+ | { status: "disabled"; backend: MuxBackend; operation: RenameOperation; setting: string };
421
+
422
+ export type RenameResult =
423
+ | { status: "renamed"; backend: MuxBackend; operation: RenameOperation; target: RenameTarget }
424
+ | { status: "unsupported"; backend: RenameBackend; operation: RenameOperation }
425
+ | { status: "disabled"; backend: MuxBackend; operation: RenameOperation; setting: string }
426
+ | { status: "failed"; backend: MuxBackend; operation: RenameOperation; target: RenameTarget; error: string };
427
+
428
+ /** 返回指定后端的真实重命名语义,不执行终端命令。 */
429
+ export function getRenameCapability(
430
+ operation: RenameOperation,
431
+ backend: MuxBackend | null = getMuxBackend(),
432
+ env: NodeJS.ProcessEnv = process.env,
433
+ ): RenameCapability {
434
+ if (!backend) return { status: "unsupported", backend: "headless", operation };
435
+
436
+ if (operation === "tab") {
437
+ if (backend === "tmux" && env[TMUX_WINDOW_RENAME_SETTING] !== ENABLED_SETTING_VALUE) {
438
+ return { status: "disabled", backend, operation, setting: TMUX_WINDOW_RENAME_SETTING };
436
439
  }
437
- const paneId = process.env.TMUX_PANE;
438
- if (!paneId) throw new Error("TMUX_PANE not set");
439
- const windowId = execFileSync("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"], {
440
- encoding: "utf8",
441
- }).trim();
442
- execFileSync("tmux", ["rename-window", "-t", windowId, title], { encoding: "utf8" });
443
- return;
440
+ const target: Record<MuxBackend, RenameTarget> = {
441
+ muxy: "pane",
442
+ cmux: "tab",
443
+ tmux: "window",
444
+ zellij: "pane",
445
+ wezterm: "tab",
446
+ herdr: "tab",
447
+ otty: "tab",
448
+ orca: "terminal",
449
+ };
450
+ return { status: "supported", backend, operation, target: target[backend] };
444
451
  }
445
452
 
446
- if (backend === "wezterm") {
447
- const paneId = process.env.WEZTERM_PANE;
448
- const args = ["cli", "set-tab-title"];
449
- if (paneId) args.push("--pane-id", paneId);
450
- args.push(title);
451
- execFileSync("wezterm", args, { encoding: "utf8" });
452
- return;
453
- }
454
-
455
- if (backend === "herdr") {
456
- renameHerdrTab(AGENT_HERDR_PANE_ID ?? "", title);
457
- return;
458
- }
459
-
460
- if (backend === "otty") {
461
- renameOttyTab(AGENT_OTTY_PANE_ID ?? "", title);
462
- return;
453
+ if (backend === "tmux" && env[TMUX_SESSION_RENAME_SETTING] !== ENABLED_SETTING_VALUE) {
454
+ return { status: "disabled", backend, operation, setting: TMUX_SESSION_RENAME_SETTING };
463
455
  }
464
-
465
- if (backend === "orca") {
466
- if (!AGENT_ORCA_TERMINAL_HANDLE) throw new Error("ORCA_TERMINAL_HANDLE not set");
467
- renameOrcaTerminal(AGENT_ORCA_TERMINAL_HANDLE, title);
468
- return;
469
- }
470
-
471
- // zellij: rename the agent's own pane
472
- const paneId = process.env.ZELLIJ_PANE_ID;
473
- if (paneId) {
474
- execFileSync("zellij", ["action", "rename-pane", title, "--pane-id", paneId], { encoding: "utf8" });
475
- } else {
476
- execFileSync("zellij", ["action", "rename-pane", title], { encoding: "utf8" });
456
+ if (backend === "herdr" && env[HERDR_WORKSPACE_RENAME_SETTING] !== ENABLED_SETTING_VALUE) {
457
+ return { status: "disabled", backend, operation, setting: HERDR_WORKSPACE_RENAME_SETTING };
477
458
  }
459
+ if (backend === "cmux") return { status: "supported", backend, operation, target: "workspace" };
460
+ if (backend === "tmux") return { status: "supported", backend, operation, target: "session" };
461
+ if (backend === "wezterm") return { status: "supported", backend, operation, target: "window" };
462
+ if (backend === "herdr") return { status: "supported", backend, operation, target: "workspace" };
463
+ return { status: "unsupported", backend, operation };
478
464
  }
479
465
 
480
- /**
481
- * 重命名当前 workspace / session。
482
- */
483
- export function renameWorkspace(title: string): void {
484
- if (isHeadlessMode()) return;
485
-
486
- const backend = requireMuxBackend();
487
-
488
- if (backend === "cmux") {
489
- execSync(`cmux workspace-action --action rename --title ${shellEscape(title)}`, {
490
- encoding: "utf8",
491
- });
492
- return;
493
- }
494
-
495
- if (backend === "muxy") {
496
- return;
497
- }
466
+ /** 将终端命令异常封装为可判别的失败结果。 */
467
+ function failedRenameResult(
468
+ capability: Extract<RenameCapability, { status: "supported" }>,
469
+ error: unknown,
470
+ ): RenameResult {
471
+ return {
472
+ status: "failed",
473
+ backend: capability.backend,
474
+ operation: capability.operation,
475
+ target: capability.target,
476
+ error: error instanceof Error ? error.message : String(error),
477
+ };
478
+ }
498
479
 
499
- if (backend === "tmux") {
500
- if (process.env.PI_SUBAGENT_RENAME_TMUX_SESSION !== "1") {
501
- return;
480
+ /** 重命名当前 tab/window/pane,并返回实际目标与执行结果。 */
481
+ export function renameCurrentTab(title: string): RenameResult {
482
+ const capability = getRenameCapability("tab");
483
+ if (capability.status !== "supported") return capability;
484
+
485
+ try {
486
+ const backend = capability.backend;
487
+ if (backend === "cmux") {
488
+ const surfaceId = process.env.CMUX_SURFACE_ID;
489
+ if (!surfaceId) throw new Error("CMUX_SURFACE_ID not set");
490
+ execSync(`cmux rename-tab --surface ${shellEscape(surfaceId)} ${shellEscape(title)}`, {
491
+ encoding: "utf8",
492
+ });
493
+ } else if (backend === "muxy") {
494
+ const paneId = AGENT_MUXY_PANE_ID;
495
+ if (!paneId) throw new Error("MUXY_PANE_ID not set");
496
+ execFileSync("muxy", ["rename-pane", "--pane", paneId, title], { encoding: "utf8" });
497
+ } else if (backend === "tmux") {
498
+ const paneId = process.env.TMUX_PANE;
499
+ if (!paneId) throw new Error("TMUX_PANE not set");
500
+ const windowId = execFileSync("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"], {
501
+ encoding: "utf8",
502
+ }).trim();
503
+ execFileSync("tmux", ["rename-window", "-t", windowId, title], { encoding: "utf8" });
504
+ } else if (backend === "wezterm") {
505
+ const paneId = process.env.WEZTERM_PANE;
506
+ const args = ["cli", "set-tab-title"];
507
+ if (paneId) args.push("--pane-id", paneId);
508
+ args.push(title);
509
+ execFileSync("wezterm", args, { encoding: "utf8" });
510
+ } else if (backend === "herdr") {
511
+ if (!renameHerdrTab(AGENT_HERDR_PANE_ID ?? "", title)) {
512
+ throw new Error(i18n.t("error.renameIncomplete", { backend: "Herdr" }));
513
+ }
514
+ } else if (backend === "otty") {
515
+ if (!renameOttyTab(AGENT_OTTY_PANE_ID ?? "", title)) {
516
+ throw new Error(i18n.t("error.renameIncomplete", { backend: "Otty" }));
517
+ }
518
+ } else if (backend === "orca") {
519
+ if (!AGENT_ORCA_TERMINAL_HANDLE) throw new Error("ORCA_TERMINAL_HANDLE not set");
520
+ if (!renameOrcaTerminal(AGENT_ORCA_TERMINAL_HANDLE, title)) {
521
+ throw new Error(i18n.t("error.renameIncomplete", { backend: "Orca" }));
522
+ }
523
+ } else {
524
+ const paneId = process.env.ZELLIJ_PANE_ID;
525
+ const args = ["action", "rename-pane", title];
526
+ if (paneId) args.push("--pane-id", paneId);
527
+ execFileSync("zellij", args, { encoding: "utf8" });
502
528
  }
503
- const paneId = process.env.TMUX_PANE;
504
- if (!paneId) throw new Error("TMUX_PANE not set");
505
- const sessionId = execFileSync(
506
- "tmux",
507
- ["display-message", "-p", "-t", paneId, "#{session_id}"],
508
- { encoding: "utf8" },
509
- ).trim();
510
- execFileSync("tmux", ["rename-session", "-t", sessionId, title], { encoding: "utf8" });
511
- return;
529
+ return { status: "renamed", backend, operation: capability.operation, target: capability.target };
530
+ } catch (error) {
531
+ return failedRenameResult(capability, error);
512
532
  }
533
+ }
513
534
 
514
- if (backend === "wezterm") {
515
- const paneId = process.env.WEZTERM_PANE;
516
- const args = ["cli", "set-window-title"];
517
- if (paneId) args.push("--pane-id", paneId);
518
- args.push(title);
519
- try {
535
+ /** 重命名当前 workspace/session/window,并明确报告不支持或未启用。 */
536
+ export function renameWorkspace(title: string): RenameResult {
537
+ const capability = getRenameCapability("workspace");
538
+ if (capability.status !== "supported") return capability;
539
+
540
+ try {
541
+ const backend = capability.backend;
542
+ if (backend === "cmux") {
543
+ execSync(`cmux workspace-action --action rename --title ${shellEscape(title)}`, {
544
+ encoding: "utf8",
545
+ });
546
+ } else if (backend === "tmux") {
547
+ const paneId = process.env.TMUX_PANE;
548
+ if (!paneId) throw new Error("TMUX_PANE not set");
549
+ const sessionId = execFileSync(
550
+ "tmux",
551
+ ["display-message", "-p", "-t", paneId, "#{session_id}"],
552
+ { encoding: "utf8" },
553
+ ).trim();
554
+ execFileSync("tmux", ["rename-session", "-t", sessionId, title], { encoding: "utf8" });
555
+ } else if (backend === "wezterm") {
556
+ const paneId = process.env.WEZTERM_PANE;
557
+ const args = ["cli", "set-window-title"];
558
+ if (paneId) args.push("--pane-id", paneId);
559
+ args.push(title);
520
560
  execFileSync("wezterm", args, { encoding: "utf8" });
521
- } catch {
522
- // Optional.
561
+ } else if (backend === "herdr") {
562
+ if (!renameHerdrWorkspace(title)) {
563
+ throw new Error(i18n.t("error.renameIncomplete", { backend: "Herdr" }));
564
+ }
523
565
  }
524
- return;
525
- }
526
-
527
- if (backend === "herdr") {
528
- renameHerdrWorkspace(title);
529
- return;
566
+ return { status: "renamed", backend, operation: capability.operation, target: capability.target };
567
+ } catch (error) {
568
+ return failedRenameResult(capability, error);
530
569
  }
531
-
532
- if (backend === "otty") {
533
- return;
534
- }
535
-
536
- if (backend === "orca") {
537
- // orca: 无独立 workspace 概念(worktree 由 Orca 管理),跳过
538
- return;
539
- }
540
-
541
- // zellij: skip session rename
542
570
  }
543
571
 
544
572
  export { renameHerdrTab, renameHerdrWorkspace };