From 69ed6534d3c94d3a0efa0f695709f88cb5975c3d Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Fri, 2 Oct 2026 09:26:23 +0800 Subject: [PATCH] release v0.1.0 --- MANIFEST.json | 270 ++++ README.md | 14 + package.json | 5 + skills/iterm-workspace-ctl/REFERENCE.md | 106 ++ skills/iterm-workspace-ctl/SKILL.md | 122 ++ skills/iterm-workspace-ctl/VERSION | 1 + skills/iterm-workspace-ctl/contract.lock.json | 133 ++ skills/iterm-workspace-ctl/interface.json | 200 +++ skills/iterm-workspace-ctl/scripts/install.sh | 41 + .../iterm-workspace-ctl/scripts/iterm-ctl.py | 1336 +++++++++++++++++ .../iterm-workspace-ctl/scripts/iterm-ctl.sh | 241 +++ .../scripts/python-library-path.sh | 12 + .../scripts/send-when-idle.sh | 260 ++++ 13 files changed, 2741 insertions(+) create mode 100644 MANIFEST.json create mode 100644 README.md create mode 100644 package.json create mode 100644 skills/iterm-workspace-ctl/REFERENCE.md create mode 100644 skills/iterm-workspace-ctl/SKILL.md create mode 100644 skills/iterm-workspace-ctl/VERSION create mode 100644 skills/iterm-workspace-ctl/contract.lock.json create mode 100644 skills/iterm-workspace-ctl/interface.json create mode 100755 skills/iterm-workspace-ctl/scripts/install.sh create mode 100755 skills/iterm-workspace-ctl/scripts/iterm-ctl.py create mode 100755 skills/iterm-workspace-ctl/scripts/iterm-ctl.sh create mode 100644 skills/iterm-workspace-ctl/scripts/python-library-path.sh create mode 100644 skills/iterm-workspace-ctl/scripts/send-when-idle.sh diff --git a/MANIFEST.json b/MANIFEST.json new file mode 100644 index 0000000..babf1cb --- /dev/null +++ b/MANIFEST.json @@ -0,0 +1,270 @@ +{ + "manifest_version": 1, + "name": "iterm-workspace-ctl", + "summary": "完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作", + "tier": 2, + "status": "stable", + "version": "2d2f88d", + "source": { + "path": "skills/iterm-workspace-ctl", + "commit": "2d2f88d", + "describe": "2d2f88d", + "dirty": false, + "packed_at": "2026-10-02T01:26:22.852Z" + }, + "platforms": [ + "darwin" + ], + "entry": { + "path": "scripts/iterm-ctl.sh" + }, + "commands": [ + "info", + "tree", + "ids", + "where", + "cur", + "focus", + "split", + "cols", + "resize", + "fullscreen", + "send", + "key", + "capture", + "profiles", + "scrollback", + "tab-list", + "tab-new", + "tab-select", + "tab-close", + "win-list", + "win-new", + "title", + "sleep", + "id", + "split-id", + "focus-id", + "send-id", + "send-raw-id", + "key-id", + "capture-id", + "wait", + "wait-id", + "close-id", + "wait-idle", + "idle-state", + "send-when-idle", + "batch" + ], + "errors": [ + "usage", + "deps", + "notfound", + "conflict", + "timeout", + "internal" + ], + "aliases": [ + "iterm-ctl" + ], + "admin": [ + "scripts/install.sh", + "scripts/python-library-path.sh", + "scripts/send-when-idle.sh" + ], + "depends_on": [], + "requires": [], + "config": [], + "external_imports": [], + "contract": { + "version": 1, + "skill": "iterm-workspace-ctl", + "tier": 2, + "platforms": [ + "darwin" + ], + "commands": { + "batch": { + "destructive": false + }, + "capture": { + "destructive": false + }, + "capture-id": { + "destructive": false + }, + "close-id": { + "destructive": false + }, + "cols": { + "destructive": false + }, + "cur": { + "destructive": false + }, + "focus": { + "destructive": false + }, + "focus-id": { + "destructive": false + }, + "fullscreen": { + "destructive": false + }, + "id": { + "destructive": false + }, + "idle-state": { + "destructive": false + }, + "ids": { + "destructive": false + }, + "info": { + "destructive": false + }, + "key": { + "destructive": false + }, + "key-id": { + "destructive": false + }, + "profiles": { + "destructive": false + }, + "resize": { + "destructive": false + }, + "scrollback": { + "destructive": false + }, + "send": { + "destructive": false + }, + "send-id": { + "destructive": false + }, + "send-raw-id": { + "destructive": false + }, + "send-when-idle": { + "destructive": false + }, + "sleep": { + "destructive": false + }, + "split": { + "destructive": false + }, + "split-id": { + "destructive": false + }, + "tab-close": { + "destructive": false + }, + "tab-list": { + "destructive": false + }, + "tab-new": { + "destructive": false + }, + "tab-select": { + "destructive": false + }, + "title": { + "destructive": false + }, + "tree": { + "destructive": false + }, + "wait": { + "destructive": false + }, + "wait-id": { + "destructive": false + }, + "wait-idle": { + "destructive": false + }, + "where": { + "destructive": false + }, + "win-list": { + "destructive": false + }, + "win-new": { + "destructive": false + } + }, + "errors": [ + "conflict", + "deps", + "internal", + "notfound", + "timeout", + "usage" + ], + "aliases": [ + "iterm-ctl" + ], + "adminAliases": [] + }, + "files": [ + { + "path": "REFERENCE.md", + "bytes": 9279, + "sha256": "fc7b7070c84bf212" + }, + { + "path": "SKILL.md", + "bytes": 10538, + "sha256": "5140c3afea686708" + }, + { + "path": "VERSION", + "bytes": 6, + "sha256": "e9dd8507f4bf0c6f" + }, + { + "path": "contract.lock.json", + "bytes": 2162, + "sha256": "19e63443daba212e" + }, + { + "path": "interface.json", + "bytes": 4420, + "sha256": "87e83ab7a4650d1c" + }, + { + "path": "scripts/install.sh", + "bytes": 1737, + "sha256": "2d1e78d675c5b269" + }, + { + "path": "scripts/iterm-ctl.py", + "bytes": 56068, + "sha256": "71cac561639e410d" + }, + { + "path": "scripts/iterm-ctl.sh", + "bytes": 16060, + "sha256": "abcd9259385d4df7" + }, + { + "path": "scripts/python-library-path.sh", + "bytes": 673, + "sha256": "0534fb415026ff6f" + }, + { + "path": "scripts/send-when-idle.sh", + "bytes": 10131, + "sha256": "610e30e710608d19" + } + ], + "leak_scan": { + "errors": 0, + "warnings": 0, + "findings": [] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..142f3e9 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# iterm-workspace-ctl + +完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作 + +以 Pi package(技能形态)发布。 +技能本体在 `skills/iterm-workspace-ctl/`,用法见其 `SKILL.md` 与 `REFERENCE.md`。 + +## 安装 + +```sh +pi install git:gitea.vhkd.top/geekinney/iterm-workspace-ctl.git@v0.1.0 +``` + +装完 `pi-skill list` 能看到 `iterm-workspace-ctl`,`pi-skill iterm-workspace-ctl check` 会告诉还缺什么。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..17bd929 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "iterm-workspace-ctl", + "version": "0.1.0", + "description": "完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作" +} diff --git a/skills/iterm-workspace-ctl/REFERENCE.md b/skills/iterm-workspace-ctl/REFERENCE.md new file mode 100644 index 0000000..2381f84 --- /dev/null +++ b/skills/iterm-workspace-ctl/REFERENCE.md @@ -0,0 +1,106 @@ +# iTerm 控制入口维护 + +当前实现使用 cookie + websocket Python API,不再使用旧 `launch API script named` 通道。`STATUS.md` 是旧通道的历史记录,不代表当前实现。 + +- 常用入口:`bash scripts/iterm-ctl.sh <命令>`;无参数或 `help` 显示帮助,`help <命令>` 显示详情。 +- 首次初始化:`bash scripts/install.sh run`。它会修改 iTerm API 偏好并安装依赖,仅在需要且用户授权时运行;迁移恢复不需要重新授权。 +- 依赖路径由 `bash scripts/python-library-path.sh path` 解析,默认 `~/Library/Caches/iterm-workspace-ctl/-<架构>`,不放共享仓库。 +- 可用 `ITERM_CTL_PYTHON` 指定解释器、`ITERM_CTL_PY_LIB` 指定私有依赖目录、`XDG_CACHE_HOME` 指定缓存根目录。所有入口复用同一个路径原语。 +- 缺库时命令入口会安装到该缓存;网络失败应报告,不回退到旧 GUI 通道或把二进制提交到配置仓库。 +- 常用只读查询:`where`(当前位置)、`tab-list`(带 current±N 相对位置)、`tab-show` / `win-show`(范围详情)、`win-list`、`capture-tab`(屏幕/回滚历史)。它们直接输出可读结果,**不要让调用方写脚本解析**;`tree [--json]` 仅供机器处理。`capture` 会读取屏幕正文,不用于无关的健康检查。不要为了验证恢复而分屏、发命令或关闭用户窗格。 +- 改本技能后跑 `python3 tests/test_entrypoints.py`(不连真实 iTerm,用假模块);真实只读冒烟:`tree`、`where`、`frame`。 +- `close`/`close-others`/`win-close` 会终止进程,必须确定用户目标,不用于测试。 +- 同步只提交技能源码。迁移前的本机库可复用到相同解释器/架构的缓存,不能把 macOS 库复制给 Windows 用。 + +## 性能事实(改入口前先读,别把慢路径加回来) + +实测(同一台机,`var` 这种单命令经 `pi-skill` 调用): + +| 环节 | 耗时 | 说明 | +| --- | --- | --- | +| `pi-skill` 包装本身 | 0.12s | node,可忽略 | +| 解析 python 库路径 | 0.10s | 已缓存到 `~/Library/Caches/iterm-workspace-ctl/.libpath` | +| `osascript` 现取 cookie | **0.8s** | 最大的单项浪费 | +| websocket 连接(不带 cookie) | 0.27s | 地板 | +| websocket 连接(新 cookie,同一次进程内) | 0.05s | 只有紧跟的那次连接快 | +| websocket 连接(复用旧 cookie) | 0.55s | 最慢:iTerm2 走回退路径 | + +由此定下的入口规矩(改 `iterm-ctl.sh` 前必读): + +1. **默认不带 cookie 直接连**;只有连接被拒才 `osascript` 取一个新的并重试一次(此时会在同一进程内立刻用掉,才享受得到 0.05s)。 +2. 若环境里已经继承了 `ITERM2_COOKIE`(别的脚本 export 的),**先 unset**:它多半已是一次性令牌用过的旧值,留着就是走 0.55s 的慢路径。 + (历史 bug:旧入口把 cookie 导出成 `ITERM_COOKIE`,而 iterm2 库读的是 `ITERM2_COOKIE` —— 白付 0.8s 还没生效。) +3. **调用方要合并调用**:单条命令 0.5s 里 0.3s 是连接。同一件事能用 `batch` 一次连完就一次连完(`X=$(cmd)` 可捕获输出)。 +4. `batch` 的捕获只取**最后一行**:`tree --json` 是多行 JSON,不能这样捕获(实测会拿到 `T={`);多行数据直接输出再用管道解析。 + +### 后台 tab 的窗格尺寸不刷新(应用层坑,写自动化必踩) + +`var columns` / `tree --json` 里某栏的尺寸,对**非活动 tab** 是旧值:关栏/切栏后读到的一直是老宽度。 +实测同一个窗格先后读到 19 / 58 / 177,而 `focus-id` 把它所在 tab 选中后**立刻**变成正确的 177。 +所以“读几何 → 据此排版”的流程必须**先 `focus-id` 自己**,否则排版全错(`longtext-panes` 就踩过这个坑)。 + +## 等目标空闲再发送(wait-idle / send-when-idle) + +用途:目标 tab/窗格正在跑任何东西(批量命令、pi/codex 之类的 TUI),要「等它手头的活干完,再替我发一句话/发一条命令」。 +目标是通用的:任何 pane/session、任何前台程序,判定只看两条与程序无关的事实。 + +```bash +pi-skill iterm-workspace-ctl idle-state # 一次性看忙闲(job/prompt/screen_hash) +pi-skill iterm-workspace-ctl wait-idle [选项] [--send "单行文本"] # 前台阻塞,可在脚本里组合 +pi-skill iterm-workspace-ctl send-when-idle start "单行文本" [选项] # 后台守候 + 台账 +pi-skill iterm-workspace-ctl send-when-idle status|logs |stop +``` + +判定语义(`--when`): + +| 值 | 空闲条件 | 适用 | +| --- | --- | --- | +| `prompt` | 前台是 shell(iTerm jobName=zsh/bash/fish/...)= 命令跑完、回到提示符 | 批量命令;静默长命令也不会误判 | +| `quiet` | 前台是别的程序,可见屏连续 `--quiet` 秒(默认 120)没变 = 停在它自己的输入框 | TUI(pi/codex/vim) | +| `auto`(默认) | 两者择先 | 通用 | + +判定成立后还会复核 `--confirm` 秒(默认 30;0=立即)再发;期间屏幕又动就重新等。 +`--poll`(默认 10s)是采样间隔,`--max`(默认 21600s,0=不限)是守候上限,超时退出码 1。 + +事实与边界: + +- 文本必须单行(含换行会在目标里提前提交);长文本用 `--file`(末尾换行去掉,内部换行直接拒绝)。 +- 目标可以是 pane 方位(`left`/`right`/`tab N`/…)或 session id;pane 方位只在 `start` 那一刻解析,之后守候按锚定到的 session id,不受焦点变化影响。 +- 静默不输出的长命令会被 quiet 误判成空闲;要安全用 `--when prompt`。发出去的文本等价于在那个窗格键入 + 回车。 +- 台账:`~/.pi/agent/local/skills/iterm-workspace-ctl/jobs/`。每个任务 `.meta`(参数/pid)、`.text`、`.state`(每轮采样,status 显示的 `as_of` 就是最近一次采样时间,也是它有 liveness 的证据)、`.log`、`.result`(退出码;`stopped` 是 stop 写的)。台账只留最近 30 个任务:更旧的、已结束的会在下次 `start` 时自动清掉(守候中的不删)。 +- 同一目标同时只允许一个守候任务;已经守候中的目标再 `start` 会直接报错(避免两条消息都发进去)。 +- `stop` 只杀守候进程树,不碰目标 session。 +- 单测:`python3 tests/test_entrypoints.py` —— 判定函数/参数解析用纯函数测,脚本 start/status/stop 用假 ctl(`ITERM_CTL_CTL` 指向假入口 + `ITERM_CTL_JOBS_DIR` 指向临时目录)测,不连真实 iTerm。 + +## 配方:把一条消息投给另一个并发会话 + +1. 用 `pi-skill iterm-workspace-ctl ids` 找目标会话 ID,按标题里的项目或主题辨认。 +2. 运行 `pi-skill iterm-workspace-ctl send-when-idle start "<文本>"`:它先连续静止 `--quiet 120` 秒,再用 `--confirm 30` 秒复核后发送并回车,不会打断对方当前这轮。 +3. 文本会成为对方的**用户消息**,必须自足地说明谁发的、发现什么、结论或修法和证据位置(提交号或文件路径),并控制在一屏内。 +4. 用 `send-when-idle status|logs |stop ` 查守候台账或停止守候;`stop` 只杀守候进程,不动目标会话。 +5. 不要用这条通道发送密码或令牌。 + +## 标签/窗口标题为什么显示正在运行的命令,怎么关 + +现象:`~ (-zsh)`、`sleep (sleep)`、`cd (sleep)` —— 标题跟着前台命令变,命令结束后恢复。**两层叠加,只关一层还会显示命令**: + +| 层 | 来源 | 关闭方法 | +| --- | --- | --- | +| shell | oh-my-zsh 的 `omz_termsupport_preexec`(`lib/termsupport.zsh`)每条命令前用 OSC 1/2 把标题改成命令,`precmd` 改回目录 | `~/.zshrc` 末尾覆盖 `omz_termsupport_preexec() { title "$ZSH_THEME_TERM_TAB_TITLE_IDLE" "$ZSH_THEME_TERM_TITLE_IDLE" }`(保留目录标题;想彻底固定用 `DISABLE_AUTO_TITLE="true"`。只对新开的 shell 生效) | +| iTerm2 | profile 的 Title 元素带 Job 位,渲染成 `名字 (job)` | Settings → Profiles → 该配置 → General → Title 里取消 Job;等价 API 是写 `Title Components` 掩码(见下) | + +iTerm2 3.6.9 掩码实测:bit1=2 是 Job;bit0=1 与 bit11=2048 都渲染会话名(`iterm2` 模块枚举只声明到 bit10=SIZE,`.title_components` 读到未知位会 ValueError,读值要用 `_simple_get`)。写共享 profile 会持久化进 plist,但**已在跑的 session 掩码是建会话时的快照**,要让当前屏幕立刻改(例如本会话标题的 `(pi)` 后缀)得逐个写会话级 profile。 + +```python +# 解释器:iTerm2 自带 "$HOME/Library/Application Support/iTerm2/iterm2env-*/versions/*/bin/python3"(含 iterm2 模块) +import asyncio, iterm2 +async def main(): + conn = await iterm2.Connection.async_create() + for p in await iterm2.Profile.async_get(conn): + m = p._simple_get('Title Components') or 0 + if p.name == 'Dark' and m & 2: # 2 = Job + await p._async_simple_set('Title Components', m & ~2) +asyncio.run(main()) +``` + +验证:新开 tab 跑 `sleep 7`,标题保持目录不变;`tree` 里 `name=` 不再出现 `(job)`。 diff --git a/skills/iterm-workspace-ctl/SKILL.md b/skills/iterm-workspace-ctl/SKILL.md new file mode 100644 index 0000000..4742276 --- /dev/null +++ b/skills/iterm-workspace-ctl/SKILL.md @@ -0,0 +1,122 @@ +--- +name: iterm-workspace-ctl +description: 完全掌控用户的 iTerm2 终端:查询(窗口/tab/窗格/session 的编号、结构、焦点、进程、路径、tty、尺寸、屏幕内容、回滚历史)与操作(分屏/多栏、焦点、发送/按键、tab/窗口开关与移动、改名、窗口几何/全屏)。用户问任何关于终端本身的自然语言问题(“左边那个 tab 是什么”“当前第几个 tab”“第二个窗口里几个 session、布局怎样”“某某在干什么”),或要求编排终端时使用。 +platforms: darwin +--- + +# iTerm2 控制 + +## 入口 + +```bash +pi-skill iterm-workspace-ctl help # 完整用法(权威:命令、参数、旗标) +pi-skill iterm-workspace-ctl <命令> [参数...] # 执行;输出与退出码原样透传 +``` + +失败 → 动作:`error=usage` 读 `pi-skill iterm-workspace-ctl help`;`error=deps` 补依赖;`error=auth` 跑本技能的登录/检查命令; +`error=notfound` 先 `list`/`status` 重新定位;`error=conflict` 先 `status`;其它非零按输出里的 `hint=` 执行。 + +**如果你支持关闭 thinking/reasoning,请先关闭再执行。不要思考布局、不要侦察、不要读源码。把用户的话按下表直接编译成一条 bash 命令执行,完成后一句话汇报。查询类问题同样编译成一条只读命令直接回答(见“查询”表),不要写 python/jq 解析输出,也不要靠推测。** + +入口(路径相对本技能目录):`bash scripts/iterm-ctl.sh <命令> [参数]` + +**写操作硬规则**:会改变终端的命令(`send` / `key` / `close` / `split` / 改名 / `tab-close` 等)目标必须写 **session id** —— `tab-new` 直接返回 id;**从某个 tab 里的 shell 发起时(如 agent-shell)直接用 `$ITERM_SESSION_ID` 当锚点**(它就是这个 tab,不随焦点变);只有不在目标 tab 里时才 `id cur` 取锚点;`cur` / `left` / `N` 是**执行瞬间**才解析的(读 iTerm 的 current tab),不是句柄,只允许在单条命令内“读一下立即用”(如 `batch`)。焦点会被坐在键盘前的人随时切走;用 `cur` 发写操作会打进用户正在输入的框里。 +**例外(高频直连)**:“只留当前窗格 / 关掉其他窗格”直接 `close-others`(缺省保留焦点窗格、内部 force 不等确认)——你能读到这句话,就说明焦点窗格是本会话所在窗格,不要先 `ids`/`tree`/`id cur` 侦察,更不要写代码解析输出。 + +窗格 X:`cur`(当前聚焦) `left` `right` `top` `bottom` `center` `1..N`。嵌套布局时 `top`/`bottom` 后可加列限定:`top center`=中栏上、`bottom right`=右栏下。 + +| 用户说 | 执行 | +|--------|------| +| 左右分屏 / 把 X 左右分 | `split v [X] [--focus]` ⚠️ v=竖线=左右,死记;默认后台不抢焦点 | +| 上下分屏 / 把 X 上下分 | `split h [X] [--focus]` | +| N 栏 / N 行 | `cols N [--focus]` / `rows N [--focus]` | +| 焦点切到 X | `focus X` | +| 在 X 执行 Y | `send X "Y"` | +| 在 X 输入 Y(不执行) | `send-raw X "Y"` | +| 打断 X 的命令 | `key X ctrl-c` | +| 给 X 按回车/ESC/方向键 | `key X enter` `esc` `up` `down` `left` `right` | +| 关掉 X 窗格 | `close X` | +| 只留 X 窗格(缺省=当前) | `close-others [X]` | +| 新开/切换/关 tab | `tab-new [--profile P] [--command C] [--focus]` / `tab-select N` / `tab-close [N]` | +| tab 独立成窗口 | `tab-detach N` | +| 新开/关窗口 | `win-new [--profile P] [--command C]` / `win-close [n\|id]` | +| 切窗口 / 把 iTerm 前置 | `win-select ` / `app-activate` | +| 看 X 的输出 / 看历史 | `capture X [N]` / `capture X N --scrollback` | +| 等 X 出现"词" | `wait X "词"` | +| 等 X 手里的活干完再跟它说句话 | `send-when-idle start X "话"`(后台守候;`send-when-idle status` / `logs ` / `stop ` 管) | +| 等 X 空闲再动手(前台阻塞,可组合) | `wait-idle X [--when auto\|prompt\|quiet]`,可加 `--send "话"` 一次做完 | +| X 现在忙不忙 | `idle-state X`(job / prompt / 屏幕指纹) | +| 看所有 session id | `ids` | +| 拿住 X 备用(锚定) | `id X` | +| 按 id 操作(与焦点无关) | `send-id "Y"` / `key-id ...` / `capture-id [N]` / `wait-id "词"` / `close-id ` / `split-id v\|h` | +| 改名 / 标题 | `title "文字"` / `tab-title N "文字"` / `win-title [n\|id] "文字"` | +| 窗口几何 / 全屏 | `frame [n\|id] [x y w h]` / `fullscreen on\|off` | +| 回滚缓冲(iTerm2 设置,新开会话生效) | `scrollback list` / `scrollback unlimited` / `scrollback set 1000 [--profile P]` | +| 按键扩展 | `key X backspace delete home end pageup pagedown F1..F12 ctrl-a..z ctrl-enter`(`ctrl-enter` 走 kitty 协议 CSI u,用于 copilot CLI 这类需要 Ctrl+Enter 排队提交的 TUI) | +| 等它干完再交代下一件事 | `wait-idle` / `send-when-idle`(判定=回 shell 提示符或屏幕连续静止;不要用 `sleep` 猜时间,语义与边界见 REFERENCE.md) | +| 多步组合 / 复杂任务 | `batch`(从 stdin 一次连接执行;默认用它) | + +## 查询:任何终端问题 → 一条命令(禁止写代码解析) + +**硬规则:回答任何问题都用下表命令直接读,一条 bash 调用拿到现成可读的输出;不要写 python/jq 解析,不要把一条问题拆成多步侦察。** + +| 用户问 | 执行 | +|--------|------| +| 我在哪?当前是第几个 tab/窗格? | `where` | +| 当前窗口有哪些 tab?左边/右边的 tab 是什么? | `tab-list`(输出带 `current-2 / current-1 / current / current+1` 标记,直接读取) | +| 第几个 tab、first/last 在干什么? | `capture-tab first\|last\|N [行数]`;看历史加 `--scrollback` | +| 某个 tab 有几个 session、布局怎样、什么进程? | `tab-show first\|last\|N` | +| 有几个窗口?某个窗口在哪、什么尺寸、全屏吗? | `win-list` / `win-show first\|last\|N` | +| 全部结构(窗口/tab/窗格/进程/路径/尺寸) | `tree`(机器处理才用 `tree --json`) | +| 某 session 的 cwd/进程/tty/任意变量 | `tab-show`/`tree` 已含 job/path/tty;或 `var <名字>` | +| 有哪些 profile | `profiles` | +| 窗口位置/大小/是否全屏 | `frame [n\|id]` / `fullscreen on\|off`(不传 on/off 只读) | +| 给某个 tab 发命令/按键(不用先查 id) | `send-tab first\|last\|N "命令"` / `key-tab first\|last\|N ctrl-c` | + +- 编号(window/tab/pane)会随增删变化:回答时以本次输出为准。 +- “在干什么”看 `tab-show` 的 job/path,“细节/最新输出”用 `capture-tab`;不要臆测。 +- 用户说“窗口”= iTerm 顶层 window;一个 window 里有多个 tab。 + +**锚定规则(跨调用必遵守)**:`left/right/cur` 是相对**当前聚焦 tab** 实时解析的,用户切 tab/窗口后同一条命令会落到别的窗格。凡是要跨多次 bash 调用或多轮对话连续操作的窗格,先 `id `(或 `ids`)拿到 session id,之后一律用 `*-id` 命令,与前台焦点完全无关。同一时刻连续动作(如 `split v && send right "pi"`)可以直接用方位。 + +**焦点规则(默认不抢)**:`split` / `split-id` / `cols` / `rows` / `tab-new` 一律在后台创建,**保持用户当前焦点不动**;只有用户明确说"切过去 / 打开给我看 / 我要看着它"时才加 `--focus`。发送、按键、读屏、关闭等 `*-id` 操作全程不动焦点。绝不主动 `focus`/`focus-id`,除非用户明确要求。 + +**批量规则(默认合并执行)**:多步任务默认写成 `batch` heredoc,**一次连接全部执行完**;只有用户明确说"一步步来 / 我要看着它执行 / 每步分开做"时才拆成单条命令逐步执行。batch 内遇错即停,行首 `#` 为注释。支持纯赋值 `A=$ITERM_SESSION_ID`;`$VAR` 没在 batch 里捕获过时退回环境变量(`$ITERM_SESSION_ID` 可直接用,`w0t1p0:` 前缀各 `*-id` 命令都认)。 + +组合:多个动作按语序用 `&&` 串成**一条** bash 调用。"等它跑起来" = `sleep 2`。分屏后新窗格在下/右:`split h center && send top "htop" && send bottom "x"` = 中栏上跑 htop、中栏下跑 x。 + +例: +- 左右分屏右边开 pi → `split v && send right "pi"` +- 三栏各开 pi → `cols 3 && send left "pi" && send center "pi" && send right "pi"` +- 打断右边再关掉 → `key right ctrl-c && close right` + +**配方(多步组合,一律 id 锚定、不抢焦点;默认用 batch)**: +- **新 tab + 左右分屏 + 左 pi / 右 proxy→codex**: + ```bash + pi-skill iterm-workspace-ctl batch <<'EOF' + T=$(tab-new) # 后台新 tab;输出左窗格 id,焦点不变 + R=$(split-id "$T" v) # 原 session 在左、新窗格在右;输出右窗格 id + send-id "$T" "pi" + send-id "$R" "proxy" + sleep 2 + send-id "$R" "codex" + EOF + ``` +- **新 tab + 多栏各自起工具**:batch 里 `T=$(tab-new)` → 逐个 `R=$(split-id "$T" v)` 收集 id → 对每个 id `send-id "命令"`。 +- **等启动完成**:用 `wait-id "词" [秒]` 代替盲目 sleep;读结果 `capture-id [行数]`;结束清理 `close-id `。 +- **在已有后台 tab 里增减窗格**:直接 batch 里 `split-id <该 tab 内任一 session id> v|h`,不会切用户焦点。 + +⚠️ `close`/`close-others` 会杀窗格里的进程,别关到用户自己正在用的窗格。 + +**复杂流程(>3 步)机械处理**:先在配方表里找相同组合,命中就直接照抄;没命中就切小段查映射表,每段单独编译,用 `&&` 串成一条。跨调用的每一步必须带 `*-id`。不要规划状态转移,不要推演“执行后窗格变成什么样”。 + +**生成命令后、执行前,机械核对(不要思考,只对照):** +□ 查询类问题是否用了“查询”表的直接命令(tab-list/tab-show/win-show/capture-tab/where)且没写代码解析? +□ 命令里每个词都在映射表里出现过? +□ 没有 info/capture/grep/ls 等侦察命令? +□ 是一条 bash 调用(多步用 && 串)? +□ 跨调用操作是否已用 `id`/`ids` 锚定,并改用 `*-id`? +□ 创建类是否默认不抢焦点(没自作主张加 `--focus`)? +全部打勾 → 立即执行。任何一项没打勾 → 重查映射表,不要自己想。 + +**只有命令报错或缺库时,才读 REFERENCE.md**(安装、排查、AppleScript 退回方案)。 diff --git a/skills/iterm-workspace-ctl/VERSION b/skills/iterm-workspace-ctl/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/skills/iterm-workspace-ctl/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/skills/iterm-workspace-ctl/contract.lock.json b/skills/iterm-workspace-ctl/contract.lock.json new file mode 100644 index 0000000..5822209 --- /dev/null +++ b/skills/iterm-workspace-ctl/contract.lock.json @@ -0,0 +1,133 @@ +{ + "version": 1, + "skill": "iterm-workspace-ctl", + "tier": 2, + "platforms": [ + "darwin" + ], + "commands": { + "batch": { + "destructive": false + }, + "capture": { + "destructive": false + }, + "capture-id": { + "destructive": false + }, + "close-id": { + "destructive": false + }, + "cols": { + "destructive": false + }, + "cur": { + "destructive": false + }, + "focus": { + "destructive": false + }, + "focus-id": { + "destructive": false + }, + "fullscreen": { + "destructive": false + }, + "id": { + "destructive": false + }, + "idle-state": { + "destructive": false + }, + "ids": { + "destructive": false + }, + "info": { + "destructive": false + }, + "key": { + "destructive": false + }, + "key-id": { + "destructive": false + }, + "profiles": { + "destructive": false + }, + "resize": { + "destructive": false + }, + "scrollback": { + "destructive": false + }, + "send": { + "destructive": false + }, + "send-id": { + "destructive": false + }, + "send-raw-id": { + "destructive": false + }, + "send-when-idle": { + "destructive": false + }, + "sleep": { + "destructive": false + }, + "split": { + "destructive": false + }, + "split-id": { + "destructive": false + }, + "tab-close": { + "destructive": false + }, + "tab-list": { + "destructive": false + }, + "tab-new": { + "destructive": false + }, + "tab-select": { + "destructive": false + }, + "title": { + "destructive": false + }, + "tree": { + "destructive": false + }, + "wait": { + "destructive": false + }, + "wait-id": { + "destructive": false + }, + "wait-idle": { + "destructive": false + }, + "where": { + "destructive": false + }, + "win-list": { + "destructive": false + }, + "win-new": { + "destructive": false + } + }, + "errors": [ + "conflict", + "deps", + "internal", + "notfound", + "timeout", + "usage" + ], + "aliases": [ + "iterm-ctl" + ], + "adminAliases": [] +} diff --git a/skills/iterm-workspace-ctl/interface.json b/skills/iterm-workspace-ctl/interface.json new file mode 100644 index 0000000..3baa2b1 --- /dev/null +++ b/skills/iterm-workspace-ctl/interface.json @@ -0,0 +1,200 @@ +{ + "summary": "完全掌控 iTerm2:查询窗口/tab/session 结构与内容,并做分屏、焦点、输入、改名、几何操作", + "useWhen": "用户问终端本身的结构/内容(哪个 tab、几个 session、布局),或要求编排 iTerm2 时", + "tier": 2, + "entry": { + "path": "scripts/iterm-ctl.sh" + }, + "aliases": [ + "iterm-ctl" + ], + "commands": [ + { + "name": "info", + "summary": "总览:窗口/tab/session 结构" + }, + { + "name": "tree", + "summary": "树状结构" + }, + { + "name": "ids", + "summary": "列出 id" + }, + { + "name": "where", + "summary": "当前焦点位置" + }, + { + "name": "cur", + "summary": "当前 session" + }, + { + "name": "focus", + "summary": "切焦点" + }, + { + "name": "split", + "summary": "分屏" + }, + { + "name": "cols", + "summary": "多栏布局" + }, + { + "name": "resize", + "summary": "调尺寸" + }, + { + "name": "fullscreen", + "summary": "全屏/取消" + }, + { + "name": "send", + "summary": "向 session 发送文本" + }, + { + "name": "key", + "summary": "发送按键" + }, + { + "name": "capture", + "summary": "读屏幕内容" + }, + { + "name": "profiles", + "summary": "列出 profile" + }, + { + "name": "scrollback", + "summary": "读/改 iTerm2 各 profile 的回滚缓冲(unlimited 或行数)" + }, + { + "name": "tab-list", + "summary": "列出 tab" + }, + { + "name": "tab-new", + "summary": "新建 tab" + }, + { + "name": "tab-select", + "summary": "切 tab" + }, + { + "name": "tab-close", + "summary": "关 tab" + }, + { + "name": "win-list", + "summary": "列出窗口" + }, + { + "name": "win-new", + "summary": "新建窗口" + }, + { + "name": "title", + "summary": "改标题" + }, + { + "name": "sleep", + "summary": "等待" + }, + { + "name": "id", + "summary": "取窗格 session id(跨调用前先锚定)" + }, + { + "name": "split-id", + "summary": "按 session id 分屏" + }, + { + "name": "focus-id", + "summary": "按 session id 聚焦" + }, + { + "name": "send-id", + "summary": "按 session id 发送文本并回车" + }, + { + "name": "send-raw-id", + "summary": "按 session id 发送文本(不回车)" + }, + { + "name": "key-id", + "summary": "按 session id 发送按键" + }, + { + "name": "capture-id", + "summary": "按 session id 读屏幕内容" + }, + { + "name": "wait", + "summary": "轮询窗格直到出现指定字样" + }, + { + "name": "wait-id", + "summary": "按 session id 轮询直到出现指定字样" + }, + { + "name": "close-id", + "summary": "按 session id 关闭窗格" + }, + { + "name": "wait-idle", + "summary": "等目标空闲(命令回提示符 / TUI 屏幕静止),可选随后发送单行文本(前台阻塞)" + }, + { + "name": "idle-state", + "summary": "一次性读目标忙闲:job/prompt/屏幕指纹" + }, + { + "name": "send-when-idle", + "summary": "后台守候:目标空闲后把它发进去并回车;start/status/logs/stop(台账与 liveness 在 status 的 as_of)", + "destructive": false, + "writes": true + }, + { + "name": "batch", + "summary": "从 stdin 一次连接顺序执行多条命令" + } + ], + "lifecycle": [ + "start", + "status", + "stop", + "logs" + ], + "liveness": "as_of", + "errors": [ + "usage", + "deps", + "notfound", + "conflict", + "timeout", + "internal" + ], + "helpListsCommands": true, + "admin": [ + { + "path": "scripts/install.sh", + "summary": "一次性初始化:开 iTerm Python API + 装 iterm2 库(幂等)" + }, + { + "path": "scripts/python-library-path.sh", + "summary": "打印本机 python 库缓存目录" + }, + { + "path": "scripts/send-when-idle.sh", + "summary": "send-when-idle 子命令的后台台账实现(由入口转发;不是独立入口)" + } + ], + "publish": { + "package_repo": "https://gitea.vhkd.top/geekinney/iterm-workspace-ctl.git" + }, + "catalog": { + "layer": "core", + "why": "别的技能建在它上面" + } +} diff --git a/skills/iterm-workspace-ctl/scripts/install.sh b/skills/iterm-workspace-ctl/scripts/install.sh new file mode 100755 index 0000000..80744ca --- /dev/null +++ b/skills/iterm-workspace-ctl/scripts/install.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# 一次性初始化:开启 iTerm2 Python API + 装 iterm2 库(幂等,可重复执行) +# +# 新版 iTerm2(3.6+)没有 "General → Scripts → Execute command enabled" 勾选, +# 改为:Python API 由偏好键 EnableAPIServer 控制,本脚本直接写入并探测。 +set -euo pipefail +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +case "${1:-help}" in + help) echo '用法: install.sh run | help [run];run 开启 iTerm Python API、安装依赖并探测。'; exit 0 ;; + run) ;; + *) echo '未知命令;使用 help' >&2; exit 1 ;; +esac +PYTHON="${ITERM_CTL_PYTHON:-python3}" +PY_LIB="$(bash "$SELF_DIR/python-library-path.sh" path)" + +echo "== 1. 开启 Python API(EnableAPIServer)==" +defaults write com.googlecode.iterm2 EnableAPIServer -bool true +echo " EnableAPIServer = $(defaults read com.googlecode.iterm2 EnableAPIServer)" + +echo "" +echo "== 2. 安装 iterm2 库到本机缓存 ==" +mkdir -p "$PY_LIB" +"$PYTHON" -m pip install --quiet --target "$PY_LIB" iterm2 websockets +echo " 已装到 $PY_LIB" + +echo "" +echo "== 3. 探测 API 是否可用 ==" +# 给 iTerm 一点时间加载偏好 +osascript -e 'tell application "iTerm" to activate' >/dev/null 2>&1 || true +sleep 2 +COOKIE=$(osascript -e 'tell application "iTerm" to request cookie' 2>/dev/null | grep -v TISFile | tr -d '[:space:]') +if [ -n "$COOKIE" ]; then + echo " ✅ Python API 已可用(拿到 cookie)" + echo "" + echo "完成。现在可以直接用:" + echo " bash $SELF_DIR/iterm-ctl.sh info" +else + echo " ⚠️ 暂时取不到 cookie。" + echo " 若 iTerm 刚升级,可能需要完全重启一次 iTerm2(⌘Q 后重开)让 API 服务加载偏好。" + echo " 重启后再跑一次本脚本验证即可。" +fi diff --git a/skills/iterm-workspace-ctl/scripts/iterm-ctl.py b/skills/iterm-workspace-ctl/scripts/iterm-ctl.py new file mode 100755 index 0000000..5b170ac --- /dev/null +++ b/skills/iterm-workspace-ctl/scripts/iterm-ctl.py @@ -0,0 +1,1336 @@ +#!/usr/bin/env python3 +""" +iterm-ctl — iTerm2 窗格/窗口通用控制(新版 asyncio API,websocket 直连) + +由 iterm-ctl.sh 调用。iterm-ctl.sh 负责: + 1. 确保 iterm2 库可用(首次自动 pip 装到本机缓存目录) + 2. 取 cookie(osascript 'request cookie') + 3. 把 窗口id + 命令 + 参数 作为 argv 传给本脚本 +本脚本直接连 iTerm 的 Python API websocket,无需 GUI、无需重启、无需任何勾选。 + +用法(经 iterm-ctl.sh,见 SKILL.md): + info 打印当前窗口各 tab 的窗格几何布局(调试/定位用) + ids 列出所有 session id 及名称(不受焦点影响) + id 打印窗格 session id(用于锚定跨调用目标) + split [pane] [--focus] 分屏:默认后台创建、保持当前焦点;--focus 切过去;打印新 session id + split-id [--focus] 在指定 session 上分屏;默认不改焦点;打印新 session id + cols [--focus] 当前 tab 均分为 N 列(默认不改焦点) + rows [--focus] 当前 tab 均分为 N 行(默认不改焦点) + focus 聚焦指定窗格 + focus-id 聚焦指定 session + send 向指定窗格输入 text 并回车(相当于执行命令) + send-id 同上,按 session id 定位(不受焦点影响) + send-raw 向指定窗格输入 text,不回车 + send-raw-id 同上,按 session id 定位 + key <按键...> 发送按键: ctrl-c ctrl-d ctrl-z ctrl-l ctrl-enter enter esc tab up down left right + key-id <按键...> 同上,按 session id 定位 + close [pane] 关闭指定窗格(默认当前) + close-id 关闭指定 session + close-others [pane] 关闭除指定窗格外的所有窗格(默认保留当前聚焦) + capture [N] [--scrollback] 读取窗格内容(默认可见屏最后 30 行;--scrollback 含回滚历史) + capture-id [N] [--scrollback] 同上,按 session id 定位 + wait [秒] 轮询窗格屏幕直到出现 pattern(默认 30 秒超时) + wait-id [秒] 同上,按 session id 定位 + wait-idle [--when auto|prompt|quiet] [--quiet 秒] [--confirm 秒] [--poll 秒] [--max 秒] + [--send 文本 | --file 文件] [--state-file 路径] + 等目标空闲(命令跑完回提示符 / TUI 屏幕静止),可随后发送单行文本 + idle-state 一次性读目标忙闲:job/prompt/screen_hash + batch 从 stdin 一次连接执行多条命令;VAR=$(cmd) 捕获、$VAR 引用、sleep N 等待 + tab-new [--focus] [--profile P] [--command C] 新建 tab(默认不抢焦点);打印新 session id + tab-close [N] 关闭第 N 个 tab(默认当前) + tab-select | tab-list 切 tab / 列出 tab + tab-detach 把第 N 个 tab 独立成新窗口;打印新窗口 id + win-new [--profile P] [--command C] 新建窗口 + win-close [n|id] 关闭窗口(默认当前) + win-select | app-activate 激活窗口 / 把 iTerm 前置 + where [--json] 当前 win/tab/pane 编号与 session 信息(回答“我在哪/现在第几个 tab”) + tree [--json] 全量结构快照:窗口/tab/窗格 + id/名称/进程/路径/tty/profile/尺寸/焦点 + profiles 列出可用 profile 名称(配合 --profile) + scrollback [list|unlimited|set N] [--profile P] 读/改各 profile 的回滚缓冲(影响新开会话) + var <名字> 读 session 变量(jobName/path/tty/user.* 等) + set-var <名字> <值> 写 session 变量 + title <文字> 设置 session 名称(tab 标题来源) + tab-title <文字> 设置当前窗口第 N 个 tab 的标题 + win-title [n|id] <文字> 设置窗口标题 + resize <列> <行> 调整窗格字符尺寸(grid) + frame [n|id] [x y w h] 读/设置窗口位置与大小 + fullscreen on|off 窗口全屏开关 + +pane 定位(按窗格 frame 坐标计算,不依赖脆弱的 tab/session 序号): + cur(默认) 当前聚焦 | left 最左 | right 最右 | top 最上 | bottom 最下 + center 最接近 tab 中心 | N 该 tab 内第 N 个窗格(1 起,按 frame 排序) + +⚠️ pane 方位是相对「当前聚焦 tab」实时解析的:用户切换 tab/窗口后,left/right/cur + 会指向另一个 tab 的窗格。跨多次调用的工作流必须先用 id 或 ids 锚定 + session id,之后一律用 *-id 命令操作。 +⚠️ 创建类命令(split/split-id/cols/rows/tab-new)默认后台创建、不改变用户当前焦点; + 只有显式加 --focus 才切过去。 +""" +import asyncio +import contextlib +import hashlib +import io +import json +import os +import re +import shlex +import sys +import time + +try: + import iterm2 +except ImportError: + print("iterm-ctl: 缺少 iterm2 库(pip install iterm2 websockets)") + sys.exit(1) + + +def die(msg): + print("iterm-ctl: " + msg) + sys.exit(1) + + +KEYS = { + "ctrl-c": "\x03", "ctrl-d": "\x04", "ctrl-z": "\x1a", "ctrl-l": "\x0c", + # kitty 键盘协议(CSI u):需要区分 Ctrl+Enter 的 TUI 用它(如 copilot CLI 的“排队提交”); + # 普通应用收到该序列无意义,不会造成实际输入。 + "ctrl-enter": "\x1b[13;5u", + "enter": "\r", "esc": "\x1b", "tab": "\t", "space": " ", "backspace": "\x7f", + "delete": "\x1b[3~", "home": "\x1b[H", "end": "\x1b[F", + "pageup": "\x1b[5~", "pagedown": "\x1b[6~", "backtab": "\x1b[Z", + "up": "\x1b[A", "down": "\x1b[B", "right": "\x1b[C", "left": "\x1b[D", +} +for _n, _seq in [(1, "\x1bOP"), (2, "\x1bOQ"), (3, "\x1bOR"), (4, "\x1bOS"), + (5, "\x1b[15~"), (6, "\x1b[17~"), (7, "\x1b[18~"), (8, "\x1b[19~"), + (9, "\x1b[20~"), (10, "\x1b[21~"), (11, "\x1b[23~"), (12, "\x1b[24~")]: + KEYS["F%d" % _n] = _seq +for _c in "abcdefghijklmnopqrstuvwxyz": + KEYS.setdefault("ctrl-" + _c, chr(ord(_c) - 96)) + +# 进程级连接句柄:win-new / profiles 这类需要 connection 的命令使用(dispatch 拿不到 main 的局部变量) +_CONNECTION = None +SESSION_ID_RE = re.compile(r"^(?:w\d+t\d+p\d+:)?[0-9A-Fa-f]{8}-[0-9A-Fa-f-]{20,}$") + + +def looks_like_session_id(arg): + return bool(arg) and bool(SESSION_ID_RE.match(arg)) + + +def resolve_session(app, win, arg, col=None): + """ 通用解析:UUID 形状按 session id,其余按方位/编号(相对当前 tab)""" + if looks_like_session_id(arg): + _, s = find_session(app, arg) + return s + return pick_pane(win.current_tab, arg, col) + + +def find_window_spec(app, spec): + """窗口定位:current(默认) | first | last | 编号(1起) | window_id""" + if spec in (None, "", "cur", "current"): + return app.current_window + if spec == "first": + return app.windows[0] + if spec == "last": + return app.windows[-1] + for i, w in enumerate(app.windows, 1): + if str(spec) == str(i) or str(spec) == str(w.window_id): + return w + die("找不到窗口: %s(可用 first/last/N/current)" % spec) + + +def resolve_tab(win, spec): + """tab 定位:current(默认) | first | last | 编号(1起);返回 (0起索引, tab)""" + tabs = win.tabs + if not tabs: + die("窗口里没有 tab") + spec = (spec or "current").strip() + if spec in ("cur", "current"): + for i, t in enumerate(tabs): + if t.tab_id == win.current_tab.tab_id: + return i, t + return 0, tabs[0] + if spec == "first": + return 0, tabs[0] + if spec == "last": + return len(tabs) - 1, tabs[-1] + if spec.isdigit(): + i = int(spec) - 1 + if 0 <= i < len(tabs): + return i, tabs[i] + die("找不到 tab: %s(可用 first/last/N/current)" % spec) + + +def layout_grid(t): + """tab 的布局网格 (cols, rows)""" + entries = tree_leaves(t) + if not entries: + return (0, 0) + cols = max(e[2] + e[4] for e in entries) + rows = max(e[1] + e[3] for e in entries) + return (cols, rows) + + +async def session_facts(s): + """session 的关键变量;单个读失败不影响整体""" + facts = {} + for var, key in (("jobName", "job"), ("path", "path"), ("tty", "tty"), ("profileName", "profile")): + try: + facts[key] = await s.async_get_variable(var) + except Exception: + facts[key] = None + return facts + + +def grid_of(s): + """窗格字符尺寸 (cols, rows);取不到返回 (None, None)""" + try: + g = s.grid_size + return (g.width, g.height) + except Exception: + return (None, None) + + +async def capture_tail(session, n=30, scrollback=False): + """捕获最后 n 行;scrollback=True 时含回滚缓冲,取不到则退回可见屏幕。""" + if scrollback: + try: + info = await session.async_get_line_info() + end = info.first_visible_line_number + info.mutable_area_height + start = max(info.overflow, end - n) + lines = await session.async_get_contents(start, max(end - start, 0)) + text = [l.string for l in lines] + if text: + return text + except Exception: + pass + return await capture_lines(session, n) + + +def find_session(app, session_id): + """按 session id 跨窗口/tab 查找(与前台焦点无关)。 + 也接受 $ITERM_SESSION_ID 的原样值(w0t1p0:UUID):调用方直接传环境变量就行,不必自己去掉前缀。""" + session_id = session_id.rsplit(":", 1)[-1] + for w in app.windows: + for t in w.tabs: + for s in t.sessions: + if s.session_id == session_id: + return t, s + die("找不到 session id=%s" % session_id) + + +def find_tab(app, tab_id): + for w in app.windows: + for t in w.tabs: + if t.tab_id == tab_id: + return t + return None + + +def frame_xywh(s): + """session.frame 是 protobuf {origin{x,y} size{width,height}} → (x,y,w,h) + ⚠️ 实测 origin.x 在"左列+右列多行"布局下会全部返回 0(iTerm API 缺陷), + 坐标仅用于展示/排序,方位定位一律走 Splitter 树(tree_leaves)。""" + f = s.frame + return (f.origin.x, f.origin.y, f.size.width, f.size.height) + + +def tree_leaves(tab): + """遍历 tab.root Splitter 树,返回各 session 的相对位置。 + 每项: (session, row, col, rowspan, colspan),行/列按真实空间关系编号, + 不依赖 frame 坐标(实测 origin.x 不可靠)。""" + out = [] + + def walk(node, row=0, col=0, rowspan=1, colspan=1): + children = getattr(node, "children", None) + if children is None: # Session 叶子 + out.append((node, row, col, rowspan, colspan)) + return + for i, c in enumerate(children): + if node.vertical: # 竖直分割线 → 子节点横向排(分列),各占 1 列、满行高 + walk(c, row, col + i, rowspan, 1) + else: # 水平分割线 → 子节点纵向排(分行),各占 1 行、满列宽 + walk(c, row + i, col, 1, colspan) + + walk(tab.root) + return out + + +def sorted_sessions(tab): + """按 (行, 列) 排序(Splitter 树空间关系),给窗格一个稳定编号""" + return [s for s, _, _, _, _ in sorted(tree_leaves(tab), key=lambda e: (e[1], e[2]))] + + +def find_window(app, win_id): + if win_id in (None, "", "current"): + return app.current_window + for w in app.windows: + if str(w.window_id) == str(win_id): + return w + die("找不到窗口 id=%s" % win_id) + + +def pick_pane(tab, spec, col_spec=None): + ss = tab.sessions + if not ss: + die("tab 内没有窗格") + if spec in (None, "", "cur", "current"): + return tab.current_session + if spec.isdigit(): + idx = int(spec) - 1 + srt = sorted_sessions(tab) + if 0 <= idx < len(srt): + return srt[idx] + die("窗格编号 %s 超出范围(共 %d 个)" % (spec, len(ss))) + # 方位定位走 Splitter 树的 (行,列) 网格,frame 坐标不可靠(见 frame_xywh 注释) + entries = tree_leaves(tab) + nrows = max(e[1] + e[3] for e in entries) + ncols = max(e[2] + e[4] for e in entries) + + def at(r, c): + """覆盖网格格点 (r,c) 的窗格""" + for s, row, col, rs, cs in entries: + if row <= r < row + rs and col <= c < col + cs: + return s + return None + + def edge(pred, name): + """pred((r,c)) 枚举候选格点,返回第一个覆盖它的窗格""" + for r in range(nrows): + for c in range(ncols): + if pred((r, c)): + s = at(r, c) + if s: + return s + die("当前布局没有%s窗格" % name) + + if spec == "left": + return edge(lambda rc: rc[1] == 0, "最左") + if spec == "right": + return edge(lambda rc: rc[1] == ncols - 1, "最右") + if spec in ("top", "bottom"): + # 支持列限定: top center / bottom right 等。col_spec 为 None 时取最左的极端行窗格。 + r = 0 if spec == "top" else nrows - 1 + cands = [e for e in entries if e[1] <= r < e[1] + e[3]] + if not cands: + die("当前布局没有%s窗格" % ("最上" if spec == "top" else "最下")) + if col_spec in ("left", "center", "right"): + col_map = {"left": 0, "center": ncols // 2, "right": ncols - 1} + target_col = col_map[col_spec] + cands = [e for e in cands if e[2] == target_col] + if not cands: + die("%s 列没有%s窗格" % (col_spec, spec)) + elif len(cands) > 1: + # 无列限定时,优先选“所在列被上下分过”的那个(通高窗格不算“上面/下面那个”) + multi = [e for e in cands + if sum(1 for x in entries if x[2] == e[2]) > 1] + if multi: + cands = multi + return cands[0][0] + if spec == "center": + return edge(lambda rc: rc == ((nrows - 1) / 2, (ncols - 1) / 2) + or rc == (nrows // 2, ncols // 2), "中心") + die("未知 pane 指定: %s" % spec) + + +async def cmd_info(app, win_id): + for i, w in enumerate(app.windows, 1): + mark = " <--" if str(w.window_id) == str(win_id) else "" + print("window %d id=%s%s" % (i, w.window_id, mark)) + for t in w.tabs: + cur = " (current)" if t.tab_id == w.current_tab.tab_id else "" + entries = sorted(tree_leaves(t), key=lambda e: (e[1], e[2])) + print(" tab: %d sessions%s" % (len(entries), cur)) + cur_id = t.current_session.session_id if t.current_session else "" + for n, (s, row, col, rs, cs) in enumerate(entries, 1): + x, y, ww, hh = frame_xywh(s) + focus = " *focused*" if s.session_id == cur_id else "" + print(" pane%d 行%d列%d x=%d y=%d w=%d h=%d name=%r%s" + % (n, row, col, x, y, ww, hh, s.name, focus)) + + +async def split_and_report(app, tab_id, target, vertical, profile=None): + """在 target session 上分屏,刷新后打印新窗格的 session id(锚定用)""" + tab = find_tab(app, tab_id) + before = {s.session_id for s in tab.sessions} if tab else set() + kwargs = {"vertical": vertical} + if profile: + kwargs["profile"] = profile + created = await target.async_split_pane(**kwargs) + await app.async_refresh() + if created is not None and getattr(created, "session_id", None): + return created.session_id + tab = find_tab(app, tab_id) + if tab: + new = [s for s in tab.sessions if s.session_id not in before] + if new: + return new[0].session_id + return "" + + +async def cmd_ids(app): + """列出所有窗口/tab 里的 session id(不受焦点影响)""" + for wi, w in enumerate(app.windows, 1): + for t in w.tabs: + cur_id = t.current_session.session_id if t.current_session else "" + for s in sorted_sessions(t): + mark = " *focused*" if s.session_id == cur_id else "" + print("%s win%d name=%r%s" % (s.session_id, wi, s.name, mark)) + + +async def cmd_tab_list(win, as_json=False): + """当前窗口的 tab 一览:编号、名称、窗格数、布局、相对当前的位置""" + cur_idx = None + for i, t in enumerate(win.tabs): + if t.tab_id == win.current_tab.tab_id: + cur_idx = i + rows = [] + for i, t in enumerate(win.tabs, 1): + s = t.current_session + cols, nrows = layout_grid(t) + rel = None + if cur_idx is not None: + rel = i - (cur_idx + 1) + rows.append({"index": i, "name": s.name if s else None, "panes": len(t.sessions), + "cols": cols, "rows": nrows, "current": rel == 0, "relative": rel, + "sessionId": s.session_id if s else None}) + if as_json: + print(json.dumps({"tabs": rows}, ensure_ascii=False, indent=2)) + return + for r in rows: + if r["relative"] is None: + mark = "" + elif r["relative"] == 0: + mark = " (current)" + else: + mark = " (current%+d)" % r["relative"] + print("%d: %r panes=%d grid=%dx%d%s" % (r["index"], r["name"], r["panes"], r["cols"], r["rows"], mark)) + + +async def cmd_win_list(app, win_id, as_json=False): + """窗口一览:编号、tab 数、位置尺寸、全屏、相对当前""" + rows = [] + for i, w in enumerate(app.windows, 1): + frame = await w.async_get_frame() + try: + fullscreen = bool(await w.async_get_fullscreen()) + except Exception: + fullscreen = None + rows.append({"index": i, "id": str(w.window_id), "current": str(w.window_id) == str(win_id), + "tabs": len(w.tabs), + "frame": {"x": frame.origin.x, "y": frame.origin.y, + "width": frame.size.width, "height": frame.size.height}, + "fullscreen": fullscreen}) + if as_json: + print(json.dumps({"windows": rows}, ensure_ascii=False, indent=2)) + return + for r in rows: + mark = " (current)" if r["current"] else "" + fs = {True: "on", False: "off", None: "?"}[r["fullscreen"]] + fr = r["frame"] + print("window %d/%d id=%s tabs=%d frame=%dx%d+%d+%d fullscreen=%s%s" + % (r["index"], len(rows), r["id"], r["tabs"], fr["width"], fr["height"], + fr["x"], fr["y"], fs, mark)) + + +async def cmd_tree(app, win_id, as_json=False, win_spec=None, tab_spec=None): + """全量结构快照:窗口 → tab → 窗格(id/名称/进程/路径/tty/profile/尺寸/焦点) + win_spec/tab_spec 可选:只输出指定窗口/指定 tab。""" + data = {"windows": []} + selected = list(enumerate(app.windows, 1)) + if win_spec is not None: + target = find_window_spec(app, win_spec) + selected = [(i, w) for i, w in selected if str(w.window_id) == str(target.window_id)] + for wi, w in selected: + frame = await w.async_get_frame() + try: + fullscreen = bool(await w.async_get_fullscreen()) + except Exception: + fullscreen = None + wd = { + "index": wi, "id": str(w.window_id), "number": w.window_number, + "current": str(w.window_id) == str(win_id), "tabCount": len(w.tabs), + "frame": {"x": frame.origin.x, "y": frame.origin.y, + "width": frame.size.width, "height": frame.size.height}, + "fullscreen": fullscreen, "tabs": [], + } + tab_rows = list(enumerate(w.tabs, 1)) + if tab_spec is not None: + ti0, t0 = resolve_tab(w, tab_spec) + tab_rows = [(ti0 + 1, t0)] + for ti, t in tab_rows: + entries = sorted(tree_leaves(t), key=lambda e: (e[1], e[2])) + ncols, nrows = layout_grid(t) + cur_id = t.current_session.session_id if t.current_session else "" + td = {"index": ti, "id": t.tab_id, "current": t.tab_id == w.current_tab.tab_id, + "panes": len(entries), "rows": nrows, "cols": ncols, "list": []} + for pi, (s, row, col, _rs, _cs) in enumerate(entries, 1): + facts = await session_facts(s) + cols, rows = grid_of(s) + td["list"].append({ + "index": pi, "sessionId": s.session_id, "name": s.name, + "active": s.session_id == cur_id, "row": row, "col": col, + "columns": cols, "rows": rows, **facts, + }) + wd["tabs"].append(td) + data["windows"].append(wd) + if as_json: + print(json.dumps(data, ensure_ascii=False, indent=2)) + return + total = len(app.windows) + for wd in data["windows"]: + cur = " (current)" if wd["current"] else "" + fr = wd["frame"] + fs = {True: "on", False: "off", None: "?"}[wd["fullscreen"]] + print("window %d/%d id=%s%s frame=%dx%d+%d+%d fullscreen=%s tabs=%d" + % (wd["index"], total, wd["id"], cur, fr["width"], fr["height"], + fr["x"], fr["y"], fs, wd["tabCount"])) + for td in wd["tabs"]: + tcur = " (current)" if td["current"] else "" + print(" tab %d/%d id=%s panes=%d grid=%dx%d%s" + % (td["index"], wd["tabCount"], td["id"], td["panes"], + td["cols"], td["rows"], tcur)) + for pd in td["list"]: + mark = " *active*" if pd["active"] else "" + print(" pane %d session=%s name=%r job=%s path=%s tty=%s profile=%s grid=%sx%s row%dcol%d%s" + % (pd["index"], pd["sessionId"], pd["name"], pd["job"], pd["path"], + pd["tty"], pd["profile"], pd["columns"], pd["rows"], pd["row"], pd["col"], mark)) + + +async def cmd_where(app, win_id, as_json=False): + """当前聚焦位置:win/tab/pane 编号 + session 信息""" + w = find_window(app, win_id) + tab = w.current_tab + s = tab.current_session + wi = next(i for i, x in enumerate(app.windows, 1) if str(x.window_id) == str(w.window_id)) + ti = next(i for i, x in enumerate(w.tabs, 1) if x.tab_id == tab.tab_id) + srt = sorted_sessions(tab) + pi = next((i for i, x in enumerate(srt, 1) if x.session_id == s.session_id), 0) + facts = await session_facts(s) + info = {"window": {"index": wi, "total": len(app.windows), "id": str(w.window_id)}, + "tab": {"index": ti, "total": len(w.tabs), "id": tab.tab_id}, + "pane": {"index": pi, "total": len(tab.sessions)}, + "session": {"id": s.session_id, "name": s.name, **facts}} + if as_json: + print(json.dumps(info, ensure_ascii=False, indent=2)) + return + print("win=%d/%d tab=%d/%d pane=%d/%d session=%s name=%r job=%s path=%s tty=%s" + % (wi, len(app.windows), ti, len(w.tabs), pi, len(tab.sessions), + s.session_id, s.name, facts["job"], facts["path"], facts["tty"])) + + +async def cmd_profiles(): + """列出可用 profile 名称(配合 --profile 使用)""" + if _CONNECTION is None: + die("profiles 需要 iTerm 连接") + for p in await iterm2.Profile.async_get(_CONNECTION): + print(p.name) + + +SCROLLBACK_PROPS = ["Guid", "Name", "Scrollback Lines", "Unlimited Scrollback"] + + +def print_scrollback(profiles, only=None): + """按 profile 打印回滚缓冲设置;无匹配则报错""" + hit = 0 + for p in profiles: + if only and p.name != only: + continue + print("profile=%s unlimited=%s lines=%s" % ( + p.name, "yes" if p.unlimited_scrollback else "no", p.scrollback_lines)) + hit += 1 + if hit == 0: + die("没有匹配的 profile: %s(scrollback list 看可用名字)" % only) + + +async def cmd_scrollback(rest): + """读/改 iTerm2 profile 的回滚缓冲(影响新开的会话)""" + if _CONNECTION is None: + die("scrollback 需要 iTerm 连接") + args = list(rest) + only = None + if "--profile" in args: + i = args.index("--profile") + if i + 1 >= len(args): + die("scrollback --profile 需要名字(scrollback list 看可用名字)") + only = args[i + 1] + args = args[:i] + args[i + 2:] + action = args[0] if args else "list" + target = None + if action == "list": + pass + elif action == "set": + if len(args) < 2: + die("scrollback set 需要 unlimited 或行数(例: scrollback set 1000)") + target = args[1] + else: + target = action + profiles = await iterm2.PartialProfile.async_query(_CONNECTION, properties=SCROLLBACK_PROPS) + if target is None: + print_scrollback(profiles, only) + return + if target == "unlimited": + lines = None + else: + try: + lines = int(target) + except ValueError: + die("scrollback 只接受 unlimited 或行数(例: scrollback unlimited / scrollback set 1000)") + if lines < 0: + die("scrollback 行数不能为负") + changed = 0 + for p in profiles: + if only and p.name != only: + continue + if lines is None: + await p.async_set_unlimited_scrollback(True) + else: + await p.async_set_unlimited_scrollback(False) + await p.async_set_scrollback_lines(lines) + changed += 1 + if changed == 0: + die("没有匹配的 profile: %s(scrollback list 看可用名字)" % only) + print("changed=%d" % changed) + print_scrollback( + await iterm2.PartialProfile.async_query(_CONNECTION, properties=SCROLLBACK_PROPS), only) + + +async def cmd_frame(app, spec, values): + """读/设置窗口位置与大小(x y w h)""" + w = find_window_spec(app, spec) + if values: + if len(values) != 4: + die("frame 需要 0 或 4 个数字(x y w h)") + x, y, ww, hh = (int(v) for v in values) + await w.async_set_frame(iterm2.util.Frame(iterm2.util.Point(x, y), iterm2.util.Size(ww, hh))) + await app.async_refresh() + frame = await w.async_get_frame() + print("window %s frame x=%d y=%d w=%d h=%d" + % (w.window_id, frame.origin.x, frame.origin.y, frame.size.width, frame.size.height)) + + +async def cmd_fullscreen(app, spec, arg): + """窗口全屏开关(读回实际状态)""" + w = find_window_spec(app, spec) + if arg in ("on", "off"): + await w.async_set_fullscreen(arg == "on") + state = await w.async_get_fullscreen() + print("window %s fullscreen=%s" % (w.window_id, "on" if state else "off")) + + +async def even_split(app, tab, direction, target): + """反复切分当前最宽的窗格,直到窗格数达到 target""" + vertical = (direction == "v") + for _ in range(50): + if len(tab.sessions) >= target: + return + await app.async_refresh() + idx = 2 if vertical else 3 + widest = max(tab.sessions, key=lambda s: frame_xywh(s)[idx]) + await widest.async_split_pane(vertical=vertical) + die("无法达到 %d 个窗格" % target) + + +async def capture_lines(session, n=30): + """读取窗格屏幕内容,返回最后 n 行非空行""" + contents = await session.async_get_screen_contents() + lines = [contents.line(i).string for i in range(contents.number_of_lines)] + return [l for l in lines if l.strip()][-n:] + + +# jobName 命中这些名字 = 前台是 shell(命令已跑完、回到提示符) +SHELL_JOBS = { + "zsh", "bash", "fish", "sh", "dash", "ksh", "tcsh", "csh", + "nu", "xonsh", "elvish", "pwsh", "oil", "osh", +} + + +def is_shell_job(job): + """前台进程名是否是 shell;也接受 /bin/zsh、-zsh 这类写法""" + name = (job or "").strip().rsplit("/", 1)[-1].lstrip("-").lower() + return name in SHELL_JOBS + + +async def idle_probe(session, lines=40): + """一次忙闲采样:前台进程名 + 可见屏最后 lines 行 + 屏幕指纹""" + try: + job = await session.async_get_variable("jobName") + except Exception: + job = None + try: + rows = await capture_lines(session, lines) + except Exception: + rows = [] + text = "\n".join(rows) + return { + "job": job or "", + "prompt": is_shell_job(job), + "hash": hashlib.md5(text.encode("utf-8", "replace")).hexdigest()[:8], + "lines": len(rows), + } + + +def idle_decision(mode, prompt, stable_sec, quiet): + """纯判定,是否该认为目标空闲(与 iTerm 无关,便于单测): + prompt=True 前台是 shell(命令已跑完) + stable_sec 屏幕连续没变的秒数;quiet 是阈值 + mode=prompt 只认提示符;quiet 只认屏幕静止;auto 两者择先""" + if mode == "prompt": + return bool(prompt) + if mode == "quiet": + return (not prompt) and stable_sec >= quiet + if prompt: + return True + return stable_sec >= quiet + + +def write_state_file(path, values): + """原子写 key=value 状态文件(给 send-when-idle status 展示);失败不影响主流程""" + if not path: + return + try: + tmp = "%s.tmp.%d" % (path, os.getpid()) + with open(tmp, "w", encoding="utf-8") as fh: + for key, value in values.items(): + fh.write("%s=%s\n" % (key, value)) + os.replace(tmp, path) + except Exception: + pass + + +def parse_wait_idle_args(rest): + """解析 wait-idle 参数;--send 吃掉其后全部词(文本),后面不能再跟旗标""" + if not rest: + die("wait-idle 需要 ") + spec = rest[0] + opts = {"when": "auto", "quiet": 120.0, "confirm": 30.0, "poll": 10.0, + "max": 21600.0, "send": None, "file": None, "state_file": None} + numbers = {"--quiet": "quiet", "--confirm": "confirm", "--poll": "poll", "--max": "max"} + i = 1 + while i < len(rest): + arg = rest[i] + if arg == "--send": + opts["send"] = " ".join(rest[i + 1:]) + i = len(rest) + elif arg == "--when": + if i + 1 >= len(rest) or rest[i + 1] not in ("auto", "prompt", "quiet"): + die("--when 需要 auto|prompt|quiet") + opts["when"] = rest[i + 1] + i += 2 + elif arg in numbers: + if i + 1 >= len(rest): + die("%s 需要一个秒数" % arg) + try: + opts[numbers[arg]] = float(rest[i + 1]) + except ValueError: + die("%s 需要秒数,收到: %s" % (arg, rest[i + 1])) + i += 2 + elif arg in ("--file", "--state-file"): + if i + 1 >= len(rest): + die("%s 需要文件路径" % arg) + opts["file" if arg == "--file" else "state_file"] = rest[i + 1] + i += 2 + else: + die("未知参数: %s" % arg) + return spec, opts + + +def load_send_text(opts): + """取要发送的文本;没给返回 None。空文本/多行报错(含换行会提前提交)""" + if opts["send"] is None and not opts["file"]: + return None + if opts["file"]: + try: + with open(opts["file"], "r", encoding="utf-8") as fh: + text = fh.read() + except OSError as exc: + die("读不到 --file %s: %s" % (opts["file"], exc)) + text = text.rstrip("\r\n") + else: + text = opts["send"] + if not text.strip(): + die("发送文本为空") + if "\n" in text or "\r" in text: + die("发送文本必须是单行(含换行会提前提交)") + return text + + +async def cmd_wait_idle(app, win, spec, opts): + """等目标空闲(回提示符或屏幕静止),可选随后发送单行文本""" + target = resolve_session(app, win, spec) + sid = target.session_id + text = load_send_text(opts) + started = time.time() + last_hash = None + stable_since = None + errors = 0 + print("wait-idle target=%s when=%s quiet=%gs confirm=%gs poll=%gs max=%gs send=%s" + % (sid, opts["when"], opts["quiet"], opts["confirm"], opts["poll"], opts["max"], + "yes" if text else "no"), flush=True) + while True: + now = time.time() + try: + probe = await idle_probe(target) + errors = 0 + except Exception as exc: + errors += 1 + write_state_file(opts["state_file"], { + "as_of": time.strftime("%F %T"), "target": sid, "verdict": "error", + "error": exc, "waited_sec": round(now - started, 1)}) + if errors >= 5: + print("✗ 连续 %d 次读不到会话(可能已关闭): %s" % (errors, exc), flush=True) + sys.exit(1) + await asyncio.sleep(opts["poll"]) + continue + if probe["hash"] != last_hash: + last_hash = probe["hash"] + stable_since = now + stable = (now - stable_since) if stable_since is not None else 0.0 + idle = idle_decision(opts["when"], probe["prompt"], stable, opts["quiet"]) + write_state_file(opts["state_file"], { + "as_of": time.strftime("%F %T"), "target": sid, "job": probe["job"], + "prompt": "yes" if probe["prompt"] else "no", "screen": probe["hash"], + "stable_sec": round(stable, 1), "waited_sec": round(now - started, 1), + "verdict": "idle" if idle else "busy"}) + if idle: + print("疑似空闲(job=%s prompt=%s stable=%gs),%gs 后复核" + % (probe["job"], "yes" if probe["prompt"] else "no", + round(stable, 1), opts["confirm"]), flush=True) + if opts["confirm"] > 0: + await asyncio.sleep(opts["confirm"]) + probe = await idle_probe(target) + now = time.time() + if probe["hash"] != last_hash: + last_hash = probe["hash"] + stable_since = now + continue + stable = (now - stable_since) if stable_since is not None else 0.0 + if not idle_decision(opts["when"], probe["prompt"], stable, opts["quiet"]): + continue + if text: + await target.async_send_text(text + "\r") + print("✓ 已发送: target=%s job=%s prompt=%s" + % (sid, probe["job"], "yes" if probe["prompt"] else "no"), flush=True) + action = "sent" + else: + print("✓ 已空闲: target=%s job=%s prompt=%s" + % (sid, probe["job"], "yes" if probe["prompt"] else "no"), flush=True) + action = "idle" + write_state_file(opts["state_file"], { + "as_of": time.strftime("%F %T"), "target": sid, "job": probe["job"], + "prompt": "yes" if probe["prompt"] else "no", "screen": probe["hash"], + "stable_sec": round(stable, 1), "waited_sec": round(now - started, 1), + "verdict": "idle", "result": action}) + return + if opts["max"] and (now - started) >= opts["max"]: + print("✗ 超时(%gs)仍未空闲:job=%s prompt=%s stable=%gs" + % (opts["max"], probe["job"], "yes" if probe["prompt"] else "no", + round(stable, 1)), flush=True) + write_state_file(opts["state_file"], { + "as_of": time.strftime("%F %T"), "target": sid, "verdict": "timeout", + "waited_sec": round(now - started, 1)}) + sys.exit(1) + await asyncio.sleep(opts["poll"]) + + +async def wait_pattern(session, pattern, timeout=30): + """轮询窗格屏幕直到出现 pattern,返回 True;超时返回 False""" + import asyncio + elapsed = 0 + interval = 1 + while elapsed < timeout: + lines = await capture_lines(session, 50) + if any(pattern in l for l in lines): + return True + await asyncio.sleep(interval) + elapsed += interval + return False + + +async def focus_anchor(app): + """记录当前焦点 (tab_id, session_id),供后台创建后还原""" + win = app.current_window + tab = win.current_tab if win else None + session = tab.current_session if tab else None + return (tab.tab_id, session.session_id) if tab and session else None + + +async def restore_focus(app, anchor): + """把焦点还原到操作前的 session;不把 iTerm 窗口/应用前置""" + if not anchor: + return + tab_id, session_id = anchor + tab = find_tab(app, tab_id) + if not tab: + return + target = next((s for s in tab.sessions if s.session_id == session_id), None) + if target: + await target.async_activate(select_tab=True, order_window_front=False) + + +def parse_pane_args(args, min_text=0): + """解析 [pane] [col] [text...],col 仅当为 left/center/right 时识别。返回 (spec, col, text_list)""" + if not args: + return None, None, [] + spec = args[0] + col = None + text_start = 1 + if spec in ("top", "bottom") and len(args) > 1 + min_text and args[1] in ("left", "center", "right"): + col = args[1] + text_start = 2 + return spec, col, args[text_start:] + + +def parse_profile_command(rest): + """解析 --profile P / --command C;未知参数报错""" + profile = command = None + i = 0 + while i < len(rest): + if rest[i] == "--profile" and i + 1 < len(rest): + profile = rest[i + 1] + i += 2 + elif rest[i] == "--command" and i + 1 < len(rest): + command = rest[i + 1] + i += 2 + else: + die("未知参数: %s(可用 --profile P --command C)" % rest[i]) + return profile, command + + +def parse_tree_args(rest): + """解析 tree 参数:--json / --win n|id|first|last / --tab first|last|N""" + as_json = "--json" in rest + win_spec = tab_spec = None + i = 0 + while i < len(rest): + if rest[i] == "--json": + i += 1 + elif rest[i] == "--win" and i + 1 < len(rest): + win_spec = rest[i + 1] + i += 2 + elif rest[i] == "--tab" and i + 1 < len(rest): + tab_spec = rest[i + 1] + i += 2 + else: + die("未知参数: %s(tree [--json] [--win n|id|first|last] [--tab first|last|N])" % rest[i]) + return as_json, win_spec, tab_spec + + +async def dispatch(app, win, win_id, cmd, rest): + if cmd == "info": + await cmd_info(app, win_id) + return + if cmd == "ids": + await cmd_ids(app) + return + if cmd == "tree": + as_json, win_spec, tab_spec = parse_tree_args(rest) + await cmd_tree(app, win_id, as_json, win_spec, tab_spec) + return + if cmd == "tab-show": + await cmd_tree(app, win_id, "--json" in rest, None, next((a for a in rest if a != "--json"), "current")) + return + if cmd == "win-show": + await cmd_tree(app, win_id, "--json" in rest, next((a for a in rest if a != "--json"), "current"), None) + return + if cmd == "win-list": + await cmd_win_list(app, win_id, "--json" in rest) + return + if cmd == "where": + await cmd_where(app, win_id, "--json" in rest) + return + if cmd == "profiles": + await cmd_profiles() + return + if cmd == "scrollback": + await cmd_scrollback(rest) + return + if cmd == "app-activate": + await app.async_activate() + print("已激活 iTerm") + return + if cmd == "win-select": + target = find_window_spec(app, rest[0] if rest else "current") + await target.async_activate() + print("已激活 window %s" % target.window_id) + return + if cmd == "frame": + await cmd_frame(app, rest[0] if rest else "current", rest[1:]) + return + if cmd == "fullscreen": + if len(rest) < 2: + die("fullscreen 需要 on|off") + await cmd_fullscreen(app, rest[0], rest[1]) + return + tab = win.current_tab + + if cmd == "split": + focus = "--focus" in rest + rest = [a for a in rest if a != "--focus"] + profile = None + if "--profile" in rest: + i = rest.index("--profile") + if i + 1 >= len(rest): + die("--profile 缺少参数") + profile = rest[i + 1] + rest = rest[:i] + rest[i + 2:] + if len(rest) < 1: + die("split 需要方向 v|h") + anchor = await focus_anchor(app) + pane = pick_pane(tab, rest[1] if len(rest) > 1 else None) + new_id = await split_and_report(app, tab.tab_id, pane, rest[0] == "v", profile) + if not focus: + await restore_focus(app, anchor) + print(new_id) + elif cmd == "split-id": + focus = "--focus" in rest + rest = [a for a in rest if a != "--focus"] + profile = None + if "--profile" in rest: + i = rest.index("--profile") + if i + 1 >= len(rest): + die("--profile 缺少参数") + profile = rest[i + 1] + rest = rest[:i] + rest[i + 2:] + if len(rest) < 2 or rest[1] not in ("v", "h"): + die("split-id 需要 ") + anchor = await focus_anchor(app) + t, target = find_session(app, rest[0]) + new_id = await split_and_report(app, t.tab_id, target, rest[1] == "v", profile) + if not focus: + await restore_focus(app, anchor) + print(new_id) + elif cmd in ("cols", "rows"): + focus = "--focus" in rest + rest = [a for a in rest if a != "--focus"] + anchor = await focus_anchor(app) + await even_split(app, tab, "v" if cmd == "cols" else "h", int(rest[0]) if rest else 2) + if not focus: + await restore_focus(app, anchor) + elif cmd == "focus": + spec, col, _ = parse_pane_args(rest) + await pick_pane(tab, spec, col).async_activate() + elif cmd == "focus-id": + if not rest: + die("focus-id 需要 ") + _, s = find_session(app, rest[0]) + await s.async_activate() + elif cmd == "id": + spec, col, _ = parse_pane_args(rest) + print(pick_pane(tab, spec, col).session_id) + elif cmd == "send": + spec, col, text_list = parse_pane_args(rest, min_text=1) + if not text_list: + die("send 需要 ") + # 用 \r 而非 \n:实测 pi 等 TUI 程序只认 \r 为回车,\n 只是插入文本不提交 + await pick_pane(tab, spec, col).async_send_text(" ".join(text_list) + "\r") + elif cmd == "send-id": + if len(rest) < 2: + die("send-id 需要 ") + _, s = find_session(app, rest[0]) + await s.async_send_text(" ".join(rest[1:]) + "\r") + elif cmd == "send-raw": + spec, col, text_list = parse_pane_args(rest, min_text=1) + if not text_list: + die("send-raw 需要 ") + await pick_pane(tab, spec, col).async_send_text(" ".join(text_list)) + elif cmd == "send-raw-id": + if len(rest) < 2: + die("send-raw-id 需要 ") + _, s = find_session(app, rest[0]) + await s.async_send_text(" ".join(rest[1:])) + elif cmd == "key": + spec, col, keys = parse_pane_args(rest, min_text=1) + if not keys: + die("key 需要 <按键...>(可用: %s)" % " ".join(KEYS)) + pane = pick_pane(tab, spec, col) + for k in keys: + if k not in KEYS: + die("未知按键: %s(可用: %s)" % (k, " ".join(KEYS))) + await pane.async_send_text(KEYS[k]) + elif cmd == "key-id": + if len(rest) < 2: + die("key-id 需要 <按键...>(可用: %s)" % " ".join(KEYS)) + _, s = find_session(app, rest[0]) + for k in rest[1:]: + if k not in KEYS: + die("未知按键: %s(可用: %s)" % (k, " ".join(KEYS))) + await s.async_send_text(KEYS[k]) + elif cmd == "close": + spec, col, _ = parse_pane_args(rest) + await pick_pane(tab, spec, col).async_close() + elif cmd == "close-id": + if not rest: + die("close-id 需要 ") + _, s = find_session(app, rest[0]) + await s.async_close() + elif cmd == "close-others": + spec, col, _ = parse_pane_args(rest) + keep = pick_pane(tab, spec, col) + closed = 0 + for s in list(tab.sessions): + if s.session_id != keep.session_id: + await s.async_close(force=True) # force: 不弹"有进程在跑"确认框 + closed += 1 + print("已关闭 %d 个窗格,保留 name=%r" % (closed, keep.name)) + elif cmd == "capture": + spec, col, extra = parse_pane_args(rest) + if spec is None: + die("capture 需要 ") + pane = pick_pane(tab, spec, col) + scroll = "--scrollback" in extra + nums = [a for a in extra if a != "--scrollback"] + n = int(nums[0]) if nums else 30 + for line in await capture_tail(pane, n, scroll): + print(line) + elif cmd == "capture-id": + if not rest: + die("capture-id 需要 [N] [--scrollback]") + _, s = find_session(app, rest[0]) + extra = rest[1:] + scroll = "--scrollback" in extra + nums = [a for a in extra if a != "--scrollback"] + n = int(nums[0]) if nums else 30 + for line in await capture_tail(s, n, scroll): + print(line) + elif cmd == "capture-tab": + if not rest: + die("capture-tab 需要 [行数] [--scrollback]") + _, t = resolve_tab(win, rest[0]) + if not t.current_session: + die("该 tab 没有 session") + extra = rest[1:] + scroll = "--scrollback" in extra + nums = [a for a in extra if a != "--scrollback"] + n = int(nums[0]) if nums else 30 + for line in await capture_tail(t.current_session, n, scroll): + print(line) + elif cmd == "send-tab": + if len(rest) < 2: + die("send-tab 需要 ") + _, t = resolve_tab(win, rest[0]) + await t.current_session.async_send_text(" ".join(rest[1:]) + "\r") + elif cmd == "key-tab": + if len(rest) < 2: + die("key-tab 需要 <按键...>") + _, t = resolve_tab(win, rest[0]) + for k in rest[1:]: + if k not in KEYS: + die("未知按键: %s(可用: %s)" % (k, " ".join(KEYS))) + await t.current_session.async_send_text(KEYS[k]) + elif cmd == "wait": + spec, col, extra = parse_pane_args(rest, min_text=1) + if spec is None or not extra: + die("wait 需要 ") + pane = pick_pane(tab, spec, col) + pattern = extra[0] + timeout = int(extra[1]) if len(extra) > 1 else 30 + if await wait_pattern(pane, pattern, timeout): + print("✓ 检测到: %s" % pattern) + sys.exit(0) + else: + print("✗ 超时(%d秒)未检测到: %s" % (timeout, pattern)) + sys.exit(1) + elif cmd == "wait-id": + if len(rest) < 2: + die("wait-id 需要 [秒]") + _, s = find_session(app, rest[0]) + pattern = rest[1] + timeout = int(rest[2]) if len(rest) > 2 else 30 + if await wait_pattern(s, pattern, timeout): + print("✓ 检测到: %s" % pattern) + sys.exit(0) + else: + print("✗ 超时(%d秒)未检测到: %s" % (timeout, pattern)) + sys.exit(1) + elif cmd == "idle-state": + if not rest: + die("idle-state 需要 ") + target = resolve_session(app, win, rest[0]) + probe = await idle_probe(target) + print("session=%s" % target.session_id) + print("job=%s" % probe["job"]) + print("prompt=%s" % ("yes" if probe["prompt"] else "no")) + print("screen_hash=%s" % probe["hash"]) + print("screen_lines=%d" % probe["lines"]) + elif cmd == "wait-idle": + spec, opts = parse_wait_idle_args(rest) + await cmd_wait_idle(app, win, spec, opts) + elif cmd == "title": + if len(rest) < 2: + die("title 需要 <文字>") + target = resolve_session(app, win, rest[0]) + await target.async_set_name(" ".join(rest[1:])) # set_variable("name") 在 iTerm 3.6 报 INVALID_NAME + print("session 名称已更新: %s" % target.session_id) + elif cmd == "tab-title": + if len(rest) < 2: + die("tab-title 需要 <文字>") + idx = int(rest[0]) - 1 + if not 0 <= idx < len(win.tabs): + die("tab 编号 %s 超出范围" % rest[0]) + await win.tabs[idx].async_set_title(" ".join(rest[1:])) + print("tab 标题已更新") + elif cmd == "win-title": + if len(rest) < 2: + die("win-title 需要 [n|id] <文字>") + target = find_window_spec(app, rest[0]) + await target.async_set_title(" ".join(rest[1:])) + print("窗口标题已更新") + elif cmd == "resize": + if len(rest) < 3: + die("resize 需要 <列> <行>") + target = resolve_session(app, win, rest[0]) + await target.async_set_grid_size(iterm2.util.Size(int(rest[1]), int(rest[2]))) + print("已调整 %s 到 %sx%s" % (target.session_id, rest[1], rest[2])) + elif cmd == "var": + if len(rest) < 2: + die("var 需要 <名字>") + target = resolve_session(app, win, rest[0]) + print(await target.async_get_variable(rest[1])) + elif cmd == "set-var": + if len(rest) < 3: + die("set-var 需要 <名字> <值>") + target = resolve_session(app, win, rest[0]) + await target.async_set_variable(rest[1], " ".join(rest[2:])) + print("变量已写入: %s" % rest[1]) + elif cmd == "tab-detach": + if len(win.tabs) < 2: + die("只有一个 tab,无法独立成窗口") + idx = (int(rest[0]) - 1) if rest else (len(win.tabs) - 1) + if not 0 <= idx < len(win.tabs): + die("tab 编号 %s 超出范围" % rest[0]) + new_win = await win.tabs[idx].async_move_to_window() + print(new_win.window_id) + elif cmd == "tab-new": + focus = "--focus" in rest + rest = [a for a in rest if a != "--focus"] + profile, command = parse_profile_command(rest) + kwargs = {} + if profile: + kwargs["profile"] = profile + if command: + kwargs["command"] = command + anchor = await focus_anchor(app) + before = {t.tab_id for t in win.tabs} + # 实测本版 iTerm 的 select=False 仍会抢焦点,故统一创建后还原锚点 + new_tab = await win.async_create_tab(select=True, **kwargs) + await app.async_refresh() + if not focus: + await restore_focus(app, anchor) + tab = find_tab(app, new_tab.tab_id) if new_tab is not None else None + if tab is None: + w = find_window(app, win_id) + fresh = [t for t in (w.tabs if w else []) if t.tab_id not in before] + tab = fresh[0] if fresh else None + if tab and tab.sessions: + print(tab.sessions[0].session_id) + elif cmd == "tab-close": + idx, _ = resolve_tab(win, rest[0] if rest else "current") + await win.tabs[idx].async_close() + elif cmd == "tab-select": + idx, _ = resolve_tab(win, rest[0] if rest else "current") + await win.tabs[idx].async_select() + elif cmd == "tab-list": + await cmd_tab_list(win, "--json" in rest) + elif cmd == "win-new": + profile, command = parse_profile_command(rest) + kwargs = {} + if profile: + kwargs["profile"] = profile + if command: + kwargs["command"] = command + new_win = await iterm2.Window.async_create(_CONNECTION, **kwargs) + print(new_win.window_id if new_win is not None else "") + elif cmd == "win-close": + target = find_window_spec(app, rest[0] if rest else "current") + await target.async_close() + print("已关闭 window %s" % target.window_id) + else: + die("未知命令: %s" % cmd) + + +VAR_RE = re.compile(r"\$(?:\{(\w+)\}|(\w+))") +CAPTURE_RE = re.compile(r"^\s*([A-Za-z_]\w*)\s*=\s*\$\((.*)\)\s*$") +ASSIGN_RE = re.compile(r"^\s*([A-Za-z_]\w*)=(\S.*)$") + + +def substitute_vars(tokens, variables, lineno): + def repl(match): + name = match.group(1) or match.group(2) + if name not in variables and name in os.environ: + return os.environ[name] # 没在 batch 里捕获过的,退回环境变量(如 $ITERM_SESSION_ID) + if name not in variables: + die("batch 第 %d 行: 未定义变量 $%s" % (lineno, name)) + return variables[name] + return [VAR_RE.sub(repl, token) for token in tokens] + + +async def cmd_batch(app, win, win_id): + """从 stdin 顺序执行多条原语命令(一次连接);VAR=$(cmd) 捕获,$VAR 引用,sleep N 等待""" + variables = {} + for lineno, raw in enumerate(sys.stdin.read().splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + assign = ASSIGN_RE.match(line) + if assign and not CAPTURE_RE.match(line): # 纯赋值 A=$ITERM_SESSION_ID / A="值"(不执行命令) + try: + value = shlex.split(assign.group(2)) + except ValueError as exc: + die("batch 第 %d 行引号不配对: %s" % (lineno, exc)) + variables[assign.group(1)] = " ".join(substitute_vars(value, variables, lineno)) + continue + match = CAPTURE_RE.match(line) + capture = match.group(1) if match else None + command = match.group(2) if match else line + try: + tokens = shlex.split(command) + except ValueError as exc: + die("batch 第 %d 行引号不配对: %s" % (lineno, exc)) + if not tokens: + continue + tokens = substitute_vars(tokens, variables, lineno) + if tokens[0] == "sleep": + if len(tokens) != 2: + die("batch 第 %d 行: sleep 需要 1 个秒数" % lineno) + await asyncio.sleep(float(tokens[1])) + continue + if tokens[0] == "batch": + die("batch 第 %d 行: batch 不能嵌套" % lineno) + out = io.StringIO() + try: + with contextlib.redirect_stdout(out): + await dispatch(app, win, win_id, tokens[0], tokens[1:]) + except SystemExit: + if out.getvalue(): + print(out.getvalue(), end="") + die("batch 第 %d 行执行失败: %s" % (lineno, line)) + except Exception as exc: + if out.getvalue(): + print(out.getvalue(), end="") + die("batch 第 %d 行异常: %s(%s)" % (lineno, line, exc)) + text = out.getvalue() + if capture: + variables[capture] = text.strip() + print("%s=%s" % (capture, text.strip())) + elif text: + print(text, end="") + + +async def main(connection): + global _CONNECTION + _CONNECTION = connection + args = sys.argv[1:] + if len(args) < 2: + die("缺少参数(用法: [args...])") + win_id, cmd, rest = args[0], args[1], args[2:] + app = await iterm2.async_get_app(connection) + # 主动刷新层级,确保拿到最新的 frame 坐标(解决 split 后定位到旧窗格的问题) + await app.async_refresh() + win = find_window(app, win_id) + if cmd == "batch": + await cmd_batch(app, win, win_id) + return + await dispatch(app, win, win_id, cmd, rest) + + +if __name__ == "__main__": + iterm2.run_until_complete(main) diff --git a/skills/iterm-workspace-ctl/scripts/iterm-ctl.sh b/skills/iterm-workspace-ctl/scripts/iterm-ctl.sh new file mode 100755 index 0000000..62f67c4 --- /dev/null +++ b/skills/iterm-workspace-ctl/scripts/iterm-ctl.sh @@ -0,0 +1,241 @@ +#!/bin/bash +# iterm-ctl.sh — 统一入口:cookie + websocket 直连 iTerm2 Python API +# +# 新版 iTerm2(3.6+)已废弃 `launch API script named` 通道和 +# `General → Scripts → Execute command enabled` 勾选。本入口改用: +# 1. osascript 'request cookie' 取 API cookie +# 2. 系统 python3 + iterm2 库 直连 websocket 执行命令 +# 无需 GUI、无需重启、无需任何勾选。 +# +# 唯一一次性前置:开启 Python API(可用 install.sh 自动做): +# defaults write com.googlecode.iterm2 EnableAPIServer -bool true +# +# 用法: iterm-ctl.sh <命令> [参数...] 完整帮助: iterm-ctl.sh help +set -euo pipefail + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +PYTHON="${ITERM_CTL_PYTHON:-python3}" + +USAGE='iterm-ctl — iTerm2 工作区控制(布局 / 焦点 / 输入 / tab / 窗口) + +用法: iterm-ctl.sh <命令> [参数...] + +命令: + info 打印当前窗口各 tab 的窗格几何布局(定位出错时先跑它) + ids 列出所有 session id 及名称(不受焦点影响) + id 打印窗格 session id(跨调用前先锚定) + split [pane] [--focus] 分屏: v=左右两列, h=上下两行;默认后台创建不抢焦点;打印新 session id + split-id [--focus] 在指定 session 上分屏;默认不改焦点;打印新 session id + cols [--focus] 当前 tab 均分为 N 列(默认不改焦点) + rows [--focus] 当前 tab 均分为 N 行(默认不改焦点) + focus 聚焦指定窗格;focus-id 按 id 聚焦 + send 向窗格输入 text 并回车(= 执行命令);send-id 按 id + send-raw 向窗格输入 text,不回车;send-raw-id 按 id + key <按键...> 发按键: ctrl-c ctrl-d ctrl-z ctrl-l ctrl-enter enter esc tab up down left right;key-id 按 id + close [pane] 关闭指定窗格(默认当前);close-id 按 id;close-others [pane] 保留一个 + capture [N] [--scrollback] 读取窗格内容(可见屏最后 N 行;--scrollback 含回滚历史);capture-id 按 id + wait [秒] 轮询窗格屏幕直到出现 pattern(默认 30 秒超时);wait-id 按 id + wait-idle [选项] 等目标空闲(命令跑完回提示符 / TUI 屏幕连续静止);可选随后发一条单行文本 + idle-state 一次性读目标忙闲(job/prompt/screen_hash) + send-when-idle <命令> 把 wait-idle 后台化并管生命周期:start/status/logs/stop(send-when-idle help) + batch 从 stdin 一次连接顺序执行多条命令;VAR=$(cmd) 捕获、$VAR 引用、sleep N 等待 + tab-new [--focus] [--profile P] [--command C] 新建 tab(默认后台创建不抢焦点);打印新 session id + tab-close [N] 关闭第 N 个 tab(默认当前) + tab-select 切换到第 N 个 tab + tab-list 列出 tab 及窗格数 + tab-detach 把第 N 个 tab 独立成新窗口;打印新窗口 id + win-new [--profile P] [--command C] 新建窗口 + win-close [n|id] 关闭窗口(默认当前) + win-select 激活窗口;app-activate 把 iTerm 前置 + where [--json] 当前 win/tab/pane 编号与 session 信息(回答“我在哪”) + tree [--json] [--win 窗口] [--tab tab] 全量/指定范围的完整结构快照 + tab-list [--json] 当前窗口 tab 一览(含相对当前的位置 current±N) + tab-show 某个 tab 的窗格/布局/进程详情(直接可读,无需解析) + win-list [--json] 窗口一览;win-show 看某个窗口详情 + capture-tab [行数] [--scrollback] 直接读某个 tab 的屏幕/历史 + send-tab "命令" 直接给某个 tab 发命令(不需 id) + key-tab 按键 直接给某个 tab 发按键 + profiles 列出可用 profile 名称 + scrollback [list|unlimited|set N] [--profile P] 读/改各 profile 的回滚缓冲(对新建会话生效) + var <名字> 读 session 变量;set-var <名字> <值> 写入 + title <文字> 设置 session 名称;tab-title <文字>;win-title [n|id] <文字> + resize <列> <行> 调整窗格字符尺寸 + frame [n|id] [x y w h] 读/设置窗口位置与大小 + fullscreen on|off 窗口全屏开关 + help [命令] 显示帮助(不带参数=总览,带参数=该命令详情) + +pane 定位(按窗格几何坐标,不依赖序号): + cur(默认,当前聚焦) left right top bottom center N(1起) + +⚠️ pane 方位相对「当前聚焦 tab」实时解析;跨多次调用请先用 id/ids 锚定 session id, + 之后一律用 *-id 命令,否则用户切换 tab 后命令会发到错误的窗格。 +⚠️ 创建类命令(split/split-id/cols/rows/tab-new)默认后台创建、不改变用户当前焦点; + 只有显式加 --focus 才切过去。 + +示例: + iterm-ctl.sh split v 左右分屏(后台创建,焦点不变) + iterm-ctl.sh split h right --focus 右边窗格上下分并切过去 + iterm-ctl.sh cols 3 左中右三栏(焦点不变) + iterm-ctl.sh send right "pi" 在当前 tab 的右窗格启动 pi + iterm-ctl.sh close right 关掉右边的窗格 + iterm-ctl.sh batch <<'EOF' 多步任务默认用 batch 一次连接完成 + T=$(tab-new) + R=$(split-id "$T" v) + send-id "$T" "pi" + send-id "$R" "proxy" + sleep 2 + send-id "$R" "codex" + EOF + WS=$(iterm-ctl.sh id right) && iterm-ctl.sh send-id "$WS" "pi" && iterm-ctl.sh capture-id "$WS" 10 + # ↑ 跨调用工作流:先锚定 id,之后不受前台焦点影响' + +CMD_HELP='split: split [pane] [--focus] 分屏;默认保持当前焦点,--focus 切过去;打印新 session id +split-id: split-id [--focus] 在指定 session 上分屏;打印新 session id +cols: cols [--focus] 当前 tab 均分为 N 列(iTerm 固定对半切,N=3 时 25/25/50) +rows: rows [--focus] 当前 tab 均分为 N 行 +focus: focus 聚焦窗格: cur|left|right|top|bottom|center|N +focus-id: focus-id 聚焦指定 session id +id: id 打印窗格 session id(默认当前聚焦窗格) +ids: ids 列出所有窗口/tab 的 session id、名称与焦点标记 +send: send 输入 text 并回车(多词 text 用引号包成一个参数) +send-id: send-id 同上,按 session id 定位(不受焦点影响) +send-raw: send-raw 输入 text 不回车 +send-raw-id: send-raw-id 同上,按 session id 定位 +key: key <按键...> 发按键(可连发): ctrl-c ctrl-d ctrl-z ctrl-l ctrl-enter enter esc tab up down left right +key-id: key-id <按键...> 同上,按 session id 定位 +close: close [pane] 关闭窗格: cur(默认)|left|right|top|bottom|center|N +close-id: close-id 关闭指定 session id +close-others: close-others [pane] 关闭除指定窗格外的所有窗格(默认保留当前,force 不弹确认) +capture: capture [N] [--scrollback] 读窗格内容(默认可见屏最后30行;--scrollback 含回滚) +capture-id: capture-id [N] [--scrollback] 同上,按 session id 定位 +tab-list: tab-list [--json] 当前窗口 tab 一览:编号/名称/窗格数/布局/相对当前位置 +tab-show: tab-show 某个 tab 的详情(窗格数/布局/session/进程/路径) +win-list: win-list [--json] 窗口一览:编号/tab 数/位置尺寸/全屏 +win-show: win-show 某个窗口详情(等价 tree --win) +capture-tab: capture-tab [行数] [--scrollback] 读某个 tab 的屏幕/历史 +send-tab: send-tab "命令" 给某个 tab 发命令并回车 +key-tab: key-tab 按键... 给某个 tab 发按键 +tree: tree [--json] [--win n|id|first|last] [--tab first|last|N] 结构快照;常见问题直接用 tab-show/win-show +tree-json: tree --json 输出完整机器可读结构(窗口/tab/窗格) +where: where [--json] 当前 win/tab/pane 编号与 session 信息(“我在哪/第几个 tab”) +profiles: profiles 列出可用 profile 名称(配合 --profile) +scrollback: scrollback [list|unlimited|set N] [--profile P] 读/改各 profile 的回滚缓冲(影响新开会话) + 例: scrollback list / scrollback unlimited / scrollback set 1000 / scrollback --profile Dark unlimited +var: var <名字> 读 session 变量(jobName/path/tty/user.* 等) +set-var: set-var <名字> <值> 写 session 变量 +title: title <文字> 设置 session 名称(tab 标题来源) +tab-title: tab-title <文字> 设置当前窗口第 N 个 tab 标题 +win-title: win-title [n|id] <文字> 设置窗口标题 +resize: resize <列> <行> 调整窗格字符尺寸(grid) +frame: frame [n|id] [x y w h] 读/设置窗口位置与大小 +fullscreen: fullscreen on|off 窗口全屏开关 +win-select: win-select 激活窗口(前置);app-activate 把整个 iTerm 前置 +tab-select: tab-select 切换到指定 tab(默认当前) +tab-close: tab-close 关闭指定 tab(默认当前) +tab-detach: tab-detach 把第 N 个 tab 独立成新窗口;打印新窗口 id +wait: wait [秒] 轮询直到出现 pattern(子串匹配) +wait-id: wait-id [秒] 同上,按 session id 定位 +wait-idle: wait-idle [--when auto|prompt|quiet] [--quiet 秒] [--confirm 秒] [--poll 秒] [--max 秒] + [--send 文本 | --file 文件] [--state-file 路径] 等目标空闲(前台阻塞);给了文本就发单行并回车 +idle-state: idle-state 一次性读目标忙闲:session/job/prompt/screen_hash/screen_lines +send-when-idle: send-when-idle start "文本" [选项] 后台守候,空闲后发进去并回车 + status [id|--all] / logs [行数] / stop 选项与判定语义见 send-when-idle help + status 里的 as_of=最近一次采样时间(守候还活着吗的 liveness 证据) +batch: batch 从 stdin 一次连接顺序执行多条命令(引号 heredoc) + 支持 VAR=$(cmd) 捕获、$VAR 引用、sleep N;任一行失败即中止 + 例: iterm-ctl.sh batch <<'EOF' / T=$(tab-new) / send-id "$T" "pi" / EOF +tab-new: tab-new [--focus] [--profile P] [--command C] 新建 tab(默认后台创建不抢焦点);打印新 session id +tab-close: tab-close [N] 关闭第 N 个 tab(默认当前) +tab-select: tab-select 切换到第 N 个 tab +tab-list: tab-list 列出 tab 及窗格数 +win-new: win-new [--profile P] [--command C] 新建窗口 +win-close: win-close [n|id] 关闭窗口(默认当前窗口,会终止窗格进程) +info: info 打印当前窗口窗格几何布局(调试定位用)' + +if [ "${1:-}" = "help" ]; then + if [ $# -ge 2 ]; then + hit=$(grep "^${2}:" <<< "$CMD_HELP" || true) + if [ -n "$hit" ]; then echo "$hit"; else echo "未知命令: $2(可用: split split-id cols rows focus focus-id id ids send send-id send-raw send-raw-id key key-id close close-id close-others capture capture-id capture-tab send-tab key-tab wait wait-id wait-idle idle-state send-when-idle batch tab-new tab-close tab-select tab-list tab-show tab-detach win-new win-close win-select win-list win-show app-activate where tree profiles scrollback var set-var title tab-title win-title resize frame fullscreen info)"; exit 1; fi + else + echo "$USAGE" + fi + exit 0 +fi +if [ $# -lt 1 ]; then + echo "$USAGE" + exit 0 +fi + +# send-when-idle(带台账的后台守候)实现独立在 send-when-idle.sh,这里只转发,不进热路径 +if [ "${1:-}" = "send-when-idle" ]; then + shift + exec bash "$SELF_DIR/send-when-idle.sh" "$@" +fi + +# ---- 热路径:这条命令每次调用都要跑一遍,凡是能省掉的都省掉 ---- +# 实测(同一台机,见 skills/iterm-workspace-ctl/REFERENCE.md): +# iTerm2 的 cookie 是一次性令牌 —— 现取一个只能让"紧接着的那次"连接 0.05s, +# 同一个 cookie 再用一次要 0.55s(iTerm2 会走回退路径),不带 cookie 0.27s。 +# 而现取 cookie 本身要付 0.8s osascript。所以:**不带 cookie 最快**, +# 只有在连接真的被拒时才去取一个现的来重试。 +# (旧实现每次无条件取 cookie 并把它设成 ITERM_COOKIE —— 库读的是 ITERM2_COOKIE, +# 等于每次白付 0.8s,还照样走慢连接。) +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/Library/Caches}/iterm-workspace-ctl" + +# 依赖目录:系统解释器没有 iterm2 时才用机器缓存里的副本(纯 python 包,不写进共享源码) +LIB_HINT="$CACHE_DIR/.libpath" +PY_LIB="" +if [ -f "$LIB_HINT" ]; then PY_LIB="$(cat "$LIB_HINT" 2>/dev/null || true)"; fi +if [ -z "$PY_LIB" ] || [ ! -d "$PY_LIB/iterm2" ]; then + PY_LIB="$(bash "$SELF_DIR/python-library-path.sh" path)" + if [ ! -d "$PY_LIB/iterm2" ]; then + mkdir -p "$PY_LIB" + echo "iterm-ctl: 首次运行,安装 iterm2 库到 $PY_LIB ..." >&2 + "$PYTHON" -m pip install --quiet --target "$PY_LIB" iterm2 websockets >&2 + fi + mkdir -p "$CACHE_DIR" + printf '%s\n' "$PY_LIB" > "$LIB_HINT" +fi + +# 定位当前窗口。AppleScript 的数字 id ≠ Python API 的 window_id(类型不同), +# 无法互转,故统一传 "current",在 Python 侧用 app.current_window。 +WIN="current" + +if [ -n "${ITERM2_COOKIE:-}" ] && [ -z "${ITERM_CTL_FRESH_COOKIE:-}" ]; then + # 调用方(iTerm2 自己的会话)给的是已用过/会过期的 cookie,留着只会走慢路径 + unset ITERM2_COOKIE +fi + +run_py() { PYTHONPATH="$PY_LIB" "$PYTHON" "$SELF_DIR/iterm-ctl.py" "$WIN" "$@"; } + +# 失败:可能是“没带 cookie 被拒/需授权”,也可能是命令本身报错。 +# 两种情况分开处理:我们自己 die() 的消息带 `iterm-ctl: ` 前缀(参数错、找不到窗格……), +# 重试没意义 → 直接报错;没有该前缀(连接被拒/授权失败)→ 取一个现的 cookie 重试一次。 +# 输出先落盘再转发:成功时也要原样还回去(stdout/stderr 分开,顺序按各自通道)。 +OUT_LOG="$(mktemp -t iterm-ctl-out)" +ERR_LOG="$(mktemp -t iterm-ctl-err)" +trap 'rm -f "$OUT_LOG" "$ERR_LOG"' EXIT +relay() { cat "$OUT_LOG"; cat "$ERR_LOG" >&2; } + +if run_py "$@" >"$OUT_LOG" 2>"$ERR_LOG"; then + relay + exit 0 +fi +if grep -q '^iterm-ctl: ' "$OUT_LOG" "$ERR_LOG"; then + relay + exit 1 +fi +COOKIE="$(osascript -e 'tell application "iTerm" to request cookie' 2>/dev/null | grep -v TISFile | tr -d '[:space:]' || true)" +if [ -z "$COOKIE" ]; then + echo "iterm-ctl: 取不到 API cookie。Python API 未开启,运行: bash $SELF_DIR/install.sh run" >&2 + relay + exit 1 +fi +# 同一个进程里立刻用掉这个新 cookie(一次性令牌:紧跟的那次连接才快) +export ITERM2_COOKIE="$COOKIE" ITERM_CTL_FRESH_COOKIE=1 +if run_py "$@" >"$OUT_LOG" 2>"$ERR_LOG"; then + relay + exit 0 +fi +relay +exit 1 diff --git a/skills/iterm-workspace-ctl/scripts/python-library-path.sh b/skills/iterm-workspace-ctl/scripts/python-library-path.sh new file mode 100644 index 0000000..409c48c --- /dev/null +++ b/skills/iterm-workspace-ctl/scripts/python-library-path.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Resolve only the private dependency path; does not install or enable any API. +set -euo pipefail +case "${1:-help}" in + help) printf '%s\n' 'Usage: python-library-path.sh path | help [path]' 'Print ITERM_CTL_PY_LIB or the per-interpreter macOS cache directory.'; exit 0 ;; + path) ;; + *) echo 'Unknown command; use help' >&2; exit 1 ;; +esac +if [ -n "${ITERM_CTL_PY_LIB:-}" ]; then printf '%s\n' "$ITERM_CTL_PY_LIB"; exit 0; fi +PYTHON="${ITERM_CTL_PYTHON:-python3}" +TAG="$("$PYTHON" -c 'import sys,platform; print(sys.implementation.cache_tag + "-" + platform.machine())')" +printf '%s/iterm-workspace-ctl/%s\n' "${XDG_CACHE_HOME:-$HOME/Library/Caches}" "$TAG" diff --git a/skills/iterm-workspace-ctl/scripts/send-when-idle.sh b/skills/iterm-workspace-ctl/scripts/send-when-idle.sh new file mode 100644 index 0000000..0329dc7 --- /dev/null +++ b/skills/iterm-workspace-ctl/scripts/send-when-idle.sh @@ -0,0 +1,260 @@ +#!/bin/bash +# send-when-idle — 等一个终端 session 空闲,再往它里面发一句话(后台守候 + 台账管理) +# +# 原语在 iterm-ctl.sh 的 wait-idle / idle-state;本脚本只负责: +# 把 wait-idle 后台化、把每个任务的进程与状态记进台账、提供 status/logs/stop。 +# 目标是通用的:任何 tab/窗格/session、任何前台命令都适用(判定只看前台进程名与屏幕内容)。 +set -uo pipefail + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +CTL="${ITERM_CTL_CTL:-$SELF_DIR/iterm-ctl.sh}" +JOBS_DIR="${ITERM_CTL_JOBS_DIR:-$HOME/.pi/agent/local/skills/iterm-workspace-ctl/jobs}" + +USAGE="send-when-idle — 等一个终端 session 空闲后,把一句话发进去(任何 pane / session) + +用法: send-when-idle <命令> [参数...] + +命令: + start <文本...> [选项] 后台守候;目标空闲后把文本发进去并回车 + start --file 文件 [选项] 文本从文件读(单行;末尾换行会去掉) + status [id|--all] 看任务台账(默认全部) + logs [行数] 看某个任务的日志(默认 30 行) + stop 停掉守候(只杀守候进程,不碰目标 session) + help 本帮助 + +选项(start): + --when auto|prompt|quiet 空闲判定,默认 auto + auto = 回到 shell 提示符,或屏幕连续静止 + prompt = 只认命令跑完、回到 shell 提示符 + quiet = 只认屏幕连续静止(TUI 停在输入框) + --quiet 秒 屏幕静止多少秒算空闲(默认 120) + --confirm 秒 判定空闲后复核多久再发(默认 30;0=立即发) + --poll 秒 采样间隔(默认 10) + --max 秒 最长守候(默认 21600 = 6 小时;0 = 不限) + +判定语义: + 目标前台是 shell(jobName=zsh/bash/...)→ 命令已跑完、回到提示符; + 其他前台程序(pi/codex/vim/...) → 可见屏连续静止 --quiet 秒 = 停在它自己的输入框。 + 静默不输出的长命令会被 quiet 误判成空闲;要绝对安全就用 --when prompt。 + +文本必须是单行:含换行会在目标里提前提交,脚本会直接拒绝。 + +台账目录: $JOBS_DIR + 每个任务一个 id,含 .meta(参数/pid)/.text(要发的话)/.log(守候日志) + /.state(最近一次采样,status 里的 as_of 就是它)/.result(退出码)。 + 只留最近 30 个任务:更旧的、已结束的会在下次 start 时自动清掉。" + +die() { echo "send-when-idle: $*" >&2; exit 1; } + +is_sid() { [[ "$1" =~ ^(w[0-9]+t[0-9]+p[0-9]+:)?[0-9A-Fa-f]{8}-[0-9A-Fa-f-]{20,}$ ]]; } + +resolve_sid() { + local spec="$1" sid + if is_sid "$spec"; then printf '%s' "${spec##*:}"; return 0; fi + sid="$(bash "$CTL" id "$spec" 2>/dev/null)" + [ -n "$sid" ] || return 1 + printf '%s' "$sid" +} + +meta_val() { sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1; } + +job_pid() { meta_val "$JOBS_DIR/$1.meta" pid; } + +alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; } + +kill_tree() { + local pid="$1" kid + for kid in $(pgrep -P "$pid" 2>/dev/null); do kill_tree "$kid"; done + kill -TERM "$pid" 2>/dev/null +} + +prune_jobs() { + local m id + for m in $(ls -1t "$JOBS_DIR"/*.meta 2>/dev/null | tail -n +31); do + id="$(basename "$m" .meta)" + [ -f "$JOBS_DIR/$id.result" ] || continue + alive "$(meta_val "$m" pid)" && continue + rm -f "$JOBS_DIR/$id".{meta,text,state,log,result,worker.sh} + done +} + +active_job_for() { + local sid="$1" m id + for m in "$JOBS_DIR"/*.meta; do + [ -e "$m" ] || continue + id="$(basename "$m" .meta)" + [ "$(meta_val "$m" sid)" = "$sid" ] || continue + [ -f "$JOBS_DIR/$id.result" ] && continue + if alive "$(meta_val "$m" pid)"; then printf '%s' "$id"; return 0; fi + done + return 1 +} + +fmt_secs() { + local s="${1%%.*}" + if [ -z "$s" ] || [ "$s" -lt 0 ] 2>/dev/null; then echo "?"; return; fi + if [ "$s" -lt 60 ]; then echo "${s}s"; elif [ "$s" -lt 3600 ]; then echo "$((s/60))m$((s%60))s"; else echo "$((s/3600))h$((s%3600/60))m"; fi +} + +cmd_start() { + local spec="" text="" file="" when="auto" quiet="120" confirm="30" poll="10" max="21600" + while [ $# -gt 0 ]; do + case "$1" in + --file) file="${2:-}"; shift 2 ;; + --when) when="${2:-}"; shift 2 ;; + --quiet) quiet="${2:-}"; shift 2 ;; + --confirm) confirm="${2:-}"; shift 2 ;; + --poll) poll="${2:-}"; shift 2 ;; + --max) max="${2:-}"; shift 2 ;; + --*) die "未知选项: $1(send-when-idle help)" ;; + *) if [ -z "$spec" ]; then spec="$1"; else text="$text $1"; fi; shift ;; + esac + done + text="${text# }" + [ -n "$spec" ] || die "start 需要 <文本...>" + case "$when" in auto|prompt|quiet) ;; *) die "--when 只能是 auto|prompt|quiet" ;; esac + local v + for v in "$quiet" "$confirm" "$poll" "$max"; do + case "$v" in ''|*[!0-9.]*) die "--quiet/--confirm/--poll/--max 需要秒数,收到: $v" ;; esac + done + if [ -n "$file" ] && [ -n "$text" ]; then die "文本和 --file 只能给一个"; fi + if [ -n "$file" ]; then + [ -r "$file" ] || die "读不到文件: $file" + case "$(cat "$file")" in *$'\n'*) die "文本必须是单行($file 含多行,换行会在目标里提前提交)" ;; esac + [ -n "$(cat "$file")" ] || die "文本为空: $file" + fi + if [ -z "$file" ] && [ -z "$text" ]; then die "start 需要 <文本...> 或 --file 文件"; fi + if [ -z "$file" ]; then + case "$text" in *$'\n'*) die "文本必须是单行(含换行会在目标里提前提交)" ;; esac + fi + + local sid id existing + sid="$(resolve_sid "$spec")" || die "定位不到目标 $spec(用 iterm-workspace-ctl ids 看 session id)" + if existing="$(active_job_for "$sid")"; then + die "目标 $sid 已有守候中的任务 id=$existing(先 send-when-idle status / stop $existing)" + fi + + prune_jobs + + id="$(printf '%s' "$sid" | cut -c1-8)-$(date +%H%M%S)" + mkdir -p "$JOBS_DIR" + local textfile="$JOBS_DIR/$id.text" state="$JOBS_DIR/$id.state" log="$JOBS_DIR/$id.log" \ + meta="$JOBS_DIR/$id.meta" result="$JOBS_DIR/$id.result" worker="$JOBS_DIR/$id.worker.sh" + if [ -n "$file" ]; then cp "$file" "$textfile" || die "复制文本失败: $file"; else printf '%s\n' "$text" > "$textfile"; fi + local preview + preview="$(head -c 200 "$textfile" | tr -d '\n')" + + cat > "$worker" <> $(printf '%q' "$log") 2>&1 +ec=\$? +echo "exit=\$ec at=\$(date '+%F %T')" >> $(printf '%q' "$log") +printf 'exit=%s\nat=%s\n' "\$ec" "\$(date '+%F %T')" > $(printf '%q' "$result") +EOF + + cat > "$meta" </dev/null 2>&1 & + echo "pid=$!" >> "$meta" + echo "id=$id" + echo "pid=$(meta_val "$meta" pid)" + echo "target=$sid" + echo "log=$log" +} + +print_job() { + local id="$1" m="$JOBS_DIR/$1.meta" pid state ec st + pid="$(meta_val "$m" pid)" + if [ -f "$JOBS_DIR/$id.result" ]; then + ec="$(meta_val "$JOBS_DIR/$id.result" exit)" + case "$ec" in + 0) state="sent (已发送)" ;; + stopped) state="stopped (已停)" ;; + *) state="exit=$ec (未发送)" ;; + esac + elif alive "$pid"; then + st="$(meta_val "$m" started_epoch)" + local waited="" + [ -n "$st" ] && waited=" 已守=$(fmt_secs "$(( $(date +%s) - st ))")" + state="running (pid $pid$waited)" + else + state="stopped (无进程,未见结果)" + fi + echo "id=$id state=$state" + echo " target=$(meta_val "$m" sid) 起=$(meta_val "$m" started)" + echo " 判定 when=$(meta_val "$m" when) quiet=$(meta_val "$m" quiet)s confirm=$(meta_val "$m" confirm)s max=$(meta_val "$m" max)s" + if [ -f "$JOBS_DIR/$id.state" ]; then + local f="$JOBS_DIR/$id.state" + echo " as_of=$(meta_val "$f" as_of) job=$(meta_val "$f" job) prompt=$(meta_val "$f" prompt) stable=$(meta_val "$f" stable_sec)s waited=$(meta_val "$f" waited_sec)s verdict=$(meta_val "$f" verdict)" + fi + echo " 文本: $(meta_val "$m" text)" + echo " 日志: $(meta_val "$m" log)" +} + +cmd_status() { + local want="${1:-}" m id found=0 + for m in "$JOBS_DIR"/*.meta; do + [ -e "$m" ] || continue + id="$(basename "$m" .meta)" + if [ -n "$want" ] && [ "$want" != "--all" ] && [ "$id" != "$want" ]; then continue; fi + found=1 + print_job "$id" + done + [ "$found" = 1 ] || echo "没有任务(send-when-idle start \"文本\";台账: $JOBS_DIR)" +} + +cmd_logs() { + local id="${1:-}" n="${2:-30}" + [ -n "$id" ] || die "logs 需要 " + [ -f "$JOBS_DIR/$id.log" ] || die "没有这个任务: $id" + tail -n "$n" "$JOBS_DIR/$id.log" +} + +cmd_stop() { + local want="${1:-}" m id pid found=0 + [ -n "$want" ] || die "stop 需要 " + for m in "$JOBS_DIR"/*.meta; do + [ -e "$m" ] || continue + id="$(basename "$m" .meta)" + if [ "$want" != "--all" ] && [ "$id" != "$want" ]; then continue; fi + found=1 + pid="$(meta_val "$m" pid)" + if [ -f "$JOBS_DIR/$id.result" ]; then + echo "id=$id 已经结束($(meta_val "$JOBS_DIR/$id.result" exit)),无需停止" + continue + fi + if alive "$pid"; then kill_tree "$pid"; sleep 0.5; fi + printf 'exit=stopped\nat=%s\n' "$(date '+%F %T')" > "$JOBS_DIR/$id.result" + echo "已停止 id=$id(pid $pid,目标 session 不受影响)" + done + [ "$found" = 1 ] || die "没有这个任务: $want" +} + +cmd="${1:-help}" +[ $# -gt 0 ] && shift +case "$cmd" in + start) cmd_start "$@" ;; + status) cmd_status "$@" ;; + logs) cmd_logs "$@" ;; + stop) cmd_stop "$@" ;; + help|-h|--help) echo "$USAGE" ;; + *) echo "$USAGE"; echo; die "未知命令: $cmd" ;; +esac