mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-27 11:15:23 +00:00
Compare commits
16
Commits
b658e52367
...
d1b11f86f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b11f86f7 | ||
|
|
221e978622 | ||
|
|
0c80a8b537 | ||
|
|
2dcec0207c | ||
|
|
bf874455db | ||
|
|
874d4da096 | ||
|
|
b7758b239e | ||
|
|
ad0bd09eb9 | ||
|
|
ca0a7c0bfd | ||
|
|
7571ba6dd9 | ||
|
|
fcdaa25b8f | ||
|
|
dc925e1eeb | ||
|
|
d2746fe5ae | ||
|
|
aff91b0972 | ||
|
|
ea6c69bddf | ||
|
|
d65ce3800c |
+1
-1
@@ -6,7 +6,7 @@
|
||||
# Node artifact files
|
||||
node_modules/
|
||||
dist/
|
||||
.pi/npm/
|
||||
.pi/
|
||||
.hippo/
|
||||
.pi-debug/
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer>
|
||||
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`(2026-07-03,`0.9.0`)。
|
||||
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`。
|
||||
- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,并以 SFTP 与有界自适应搜索提供独立的 `ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 与 `ssh_bash` 工具;运行时不调用 OpenSSH 或 `sshpass`。
|
||||
- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,以 `ssh_cd` 显式维护远端工作区,并以 SFTP 与有界自适应搜索提供独立的远端工具;运行时不调用 OpenSSH 或 `sshpass`。
|
||||
- 上游来源:<https://github.com/pansapiens/pi-ssh>
|
||||
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`(2026-06-23,`0.7.0`)。
|
||||
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`、`node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`。
|
||||
@@ -20,6 +20,10 @@
|
||||
- 上游来源:<https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system>
|
||||
- 初始导入快照:`ec4fdb11343dc94f7185b113e559a4cf9f8dc035`(`pi-permission-system-v26.2.1`)。
|
||||
- 该目录从明确 tag 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||
- `pi-subagents/`:提供 Claude Code 风格的隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent。
|
||||
- 上游来源:<https://github.com/tintinweb/pi-subagents>
|
||||
- 初始导入快照:`3f9d35cd078d18a141eb5a6d8f4fc5010d756280`(`v0.18.0`)。
|
||||
- 该目录从明确 tag 导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留上游 `.pi` 状态;当前通过本地 `file:` 根依赖和 `extensions/subagents.ts` 默认加载,已实现权限 child lifecycle、根 authority `ask` 转发、根 transcript auto-review 身份桥接和 exact-path mandatory permission wrapper,3 个根编排工具由 Tool Search 的 `subagents` 组按需加载。
|
||||
- `extensions/fff-override.ts`:强制使用 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 FFF 的 `@` 补全;显式 CLI flag 仍遵循 FFF 官方优先级。
|
||||
- `extensions/tavily-override.ts`:加载 `@tavily/pi-extension`,把上游工具重命名为 `tavily_web_search` / `tavily_web_fetch`,并同步改写 label 与工具提示中的内部名称。
|
||||
- `@keenable/pi-search`:加载 `keenable_search` / `keenable_fetch` 及其 skill,默认 keyless,`KEENABLE_API_KEY` 仅用于提高速率限制。
|
||||
@@ -50,14 +54,18 @@
|
||||
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
|
||||
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。
|
||||
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||
- `pi-notify/`:基于 `@smoose/pi-notify` 的 Kitty 优先完成通知扩展,使用 OSC 99 精确聚焦来源 window/pane,并在 Pi 完全 settled 后通知。
|
||||
- 上游来源:<https://github.com/smoosex/pi-notify>
|
||||
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。
|
||||
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||
- `pi-extension-codex-fast-mode/`:为符合条件的 Codex Responses 请求设置 priority service tier,并把新会话默认值跨会话持久化。
|
||||
- 上游来源:<https://github.com/Firstp1ck/pi-coding-agent-forge/tree/main/pi-extension-codex-fast-mode>
|
||||
- 初始导入快照:`f1d0efd24a7f4ae99d19e10c5f4c3770a3bdd845`(`@firstpick/pi-extension-codex-fast-mode@0.1.1`)。
|
||||
- 该目录从上游源码仓库导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。
|
||||
- `pi-condense@2.9.1`:总结已完成的工具调用批次,以短 stub 替换历史原始输出,并通过 `context_tree_query` 按需恢复;组合包在用户尚未配置 `contextPrune.enabled` 时默认开启。
|
||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-chrome@0.15.46`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`pi-ask-user`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
||||
- 根包还固定安装 `@tavily/pi-extension@0.1.2`、`@keenable/pi-search@0.1.2`、`pi-chrome@0.15.46`、`pi-hashline-edit@0.8.3`、`typescript-language-server@5.3.0`、`typescript@6.0.3` 和 `pi-context-view`;`@firstpick/pi-extension-codex-fast-mode`、`@gotgenes/pi-permission-system`、`@smoose/pi-notify`、`@tintinweb/pi-subagents`、`pi-ask-user`、`pi-minimal-footer`、`pi-ssh` 与 `pi-tool-search` 分别指向仓库内对应目录的本地 `file:` 依赖。
|
||||
- `install.sh`:先安装根组合包,再交互检查 Hippo Memory CLI、Kitty/Solarized Dark、Oh My Zsh/Powerlevel10k/Zsh 插件、CodeGraph、Kotlin LSP、Java 21+ 和 JDT LS;缺失项目只在用户明确选择 `Y` 后安装或配置。Hippo 项目目录与 `hippo init` 始终由用户自行决定。Powerlevel10k 默认配置来自仓库内置的 `config/p10k.zsh`(当前 Rainbow/ASCII 单行紧凑主题)。
|
||||
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
||||
- `update.sh`:先通过 `pi update` 升级根组合包,再只升级当前已安装的终端环境和机器级依赖;未安装项直接跳过。升级成功后只能调用可定位的更新后组合包 Node helper 检查 Pi agent `settings.json`:只有 `toolSearch` 完整精确匹配旧版 `3` 组 / `20` 工具默认快照时,才创建从生成起即为权限 `600` 的时间戳备份、以同目录临时文件原子迁移到 `5` / `28` 并写入 `bundleDefaultsVersion: 2`;任何自定义或无效配置必须保留,提交前检测到内容变化必须中止。无法从 `PI_PACKAGE_SOURCE` 定位更新后 helper 时必须保留配置并报告失败,不能回退到可能陈旧的脚本目录 helper。其他组件升级前先查询并比较本地与远端版本,只有版本不同时才下载或替换;Powerlevel10k 配置优先从 `pi update` 后的已安装组合包读取,并与 `.zshrc` 受管块一起按内容比较后增量同步。
|
||||
- `uninstall.sh`:先移除根组合包;若检测到 Hippo Memory CLI,再明确询问是否卸载确认属于 npm 全局安装的 `hippo-memory`,默认保留且始终不删除 `.hippo/` 或用户记忆数据。其他共享终端环境和机器工具不卸载。
|
||||
|
||||
## 当前职责与默认行为
|
||||
@@ -71,7 +79,7 @@
|
||||
- Hashline 默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 和 `replaceText` 均关闭;它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
|
||||
- 工具路由规则不得替换 Pi 默认系统提示词:代码结构、调用关系和待修改 symbol 优先 CodeGraph,定义/引用/类型/诊断优先 LSP;工作区内大文件探索、分析和总结优先 `ctx_execute_file`,日志、构建与不可预测命令输出优先 Context Mode;字面搜索先用 FFF `find` 收敛文件位置,再在收敛路径内用 `grep`/`multi_grep` 获取行号;真正修改前才用小范围 Hashline `read` 获取新鲜锚点并用锚定 `edit`。在线搜索按场景只先选一个服务:广泛发现、新闻和候选来源用 Tavily,官方/技术/论文和高确定性来源用 Exa,中文、站点限定和日期筛选用 Keenable;只有必要时才跨服务复核。遇到大量结果、截断或上限时继续缩小范围,不得靠提高 limit 或倾倒全部结果解决。
|
||||
- `ask_user_question` 只用于模型继续任务所必需的用户决定、偏好、确认或澄清;不得重复询问直接用户消息中已有的信息。工具仅支持交互式 TUI、顺序执行和最多 8 题;取消或 abort 必须返回 `cancelled: true`,不得伪造答案,且 `/qna` 不属于该扩展。其成功 `details.answers` 必须保留 `question` / `answer` 字段,以匹配 AutoReview 已有的可信结构化用户交互边界。
|
||||
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics` 与 `tool_search`;其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组。默认最多 3 个动态组、模型组最多 8 个工具、动态工具总数最多 20,加载或组内调用更新 LRU,超限先移除最久未用组。
|
||||
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
|
||||
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。Tool Search 重新激活 schema 不能替代上游每次执行时的授权校验。
|
||||
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
|
||||
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。
|
||||
@@ -82,10 +90,12 @@
|
||||
- Exa MCP 使用托管 Streamable HTTP 端点和 `eager` lifecycle;`EXA_API_KEY` 通过 `x-api-key` 请求头发送,不得放入 URL、仓库文件或日志。所有在线搜索工具统一采用“来源名 + 原始语义工具名”的命名形式,例如 `tavily_web_search`、`exa_web_search`、`keenable_search`。Exa 高级搜索必须显式约束结果数量和文本长度;Tavily 输出默认控制 `max_results` 且非必要不请求 raw content;Keenable 优先利用中文、站点和日期筛选能力。
|
||||
- 搜索 key 默认保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/search.env`,文件必须为 `600` 且不得提交;`search_config.sh` 只输出配置状态,禁止回显 key。
|
||||
- `pi-context-view` 只观察上下文占用,不参与压缩策略。
|
||||
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。
|
||||
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只为 `settings.json` 的 `contextPrune` 补入缺失的组合包默认字段,必须保留全部用户显式值(包括 `enabled: false`),配置无效时不得覆盖原文件,配置完整时不得重复改写。组合包默认开启,使用 `agent-message` 触发、`turn` 批处理、73.5% 总预算与 4% 单轮增量触发,摘要模型和 thinking 均为 `default`,空闲/总超时为 90 秒/5 分钟,`quietOversizedSkips` 为 `false`;未显式部署的参数继续沿用上游默认,包括 skill 路径保护。
|
||||
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
|
||||
- Kitty 通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
|
||||
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文的隐私模式,非 TUI 模式不得写入 OSC 序列。
|
||||
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。
|
||||
- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。
|
||||
- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。
|
||||
- `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。
|
||||
- reviewer 返回 `allow` 时自动批准、返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。`pi-permission-system` 的 delegation envelope 继续禁止 authorizer 自动批准全部 `path` 请求;`external_directory` 对内置只读路径工具(`read`、`find`、`grep`、`ls`)接受 reviewer 的 `allow`,写入、编辑、Bash、未知工具和其他外部访问仍转人工。
|
||||
- 默认 reviewer 为 `openai-codex/codex-auto-review`、low reasoning、90 秒总重试预算和内置 Codex Guardian 风格策略;只把 active branch 中的直接用户消息与已识别结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。
|
||||
@@ -98,7 +108,7 @@
|
||||
- `uninstall.sh` 只在根包移除成功后询问是否卸载 npm 全局 Hippo CLI,默认拒绝;不得删除项目 `.hippo/`、全局记忆或旧 Hermes 数据。
|
||||
- `install.sh` 默认从 `git:git@bitbucket.org:siakitem/my-pi.git` 安装,并允许用 `PI_PACKAGE_SOURCE` 覆盖来源;组合包安装失败时立即停止,机器级依赖安装失败时继续检查其余依赖并最终返回非零状态。
|
||||
- CodeGraph 的可选安装使用官方远程安装脚本;macOS Kitty 使用 Homebrew cask,其他系统使用 Kitty 官方安装器;Kotlin LSP、Java 21 和 JDT LS 使用 Homebrew;Oh My Zsh 使用官方安装器,Powerlevel10k 与两个第三方 Zsh 插件从各自官方 Git 仓库克隆。升级时 Git checkout、Homebrew 包、Kitty 官方安装和 CodeGraph 都必须先比较本地与远端版本,版本一致时不得重新下载安装;无法可靠比较时保留现状并报告。缺少 Homebrew、curl 或 git 时保留已经完成的组合包安装并报告失败。
|
||||
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件及同步已有受管配置,但不得安装缺失组件或改写受管块之外的用户配置;卸载仅可在用户当次明确确认后移除 npm 全局 Hippo CLI,其他共享机器级工具保持不变。
|
||||
- `install.sh` 不检查默认不需要的 `rtk` CLI;`update.sh` 和 `uninstall.sh` 使用同一个 `PI_PACKAGE_SOURCE` 规则分别调用 `pi update` 和 `pi remove`。运行 `update.sh` 视为明确授权升级已安装组件及同步已有受管配置;其中 Tool Search 只能自动迁移完整匹配仓库旧默认快照的配置,不能逐字段猜测或覆盖自定义值。不得安装缺失组件或改写受管块之外的用户配置;卸载仅可在用户当次明确确认后移除 npm 全局 Hippo CLI,其他共享机器级工具保持不变。
|
||||
|
||||
## 修改边界
|
||||
|
||||
@@ -109,9 +119,12 @@
|
||||
- 需要原生构建的依赖只按锁定版本加入根 `allowScripts`;当前 `better-sqlite3@12.11.1` 仍由 Context Mode 使用并保持精确放行。官方 Hippo CLI 是独立机器级 npm 包,不得因此向根包批量批准其他 install scripts。
|
||||
- Pi 核心包只作为宿主 peer dependencies,不得在组合包内再安装或打包一套 Pi runtime;保留根 `.npmrc` 的 peer 安装策略。
|
||||
- `pi-tool-search/` 的根依赖必须保持 `file:./pi-tool-search`,组合包直接加载 `pi-tool-search/extensions/index.ts`;同步上游时先核对本地增量动态工具改造,禁止用旧 npm 入口或上游旧宿主实现直接覆盖。
|
||||
- 新引入或同步自维护扩展时,必须先联网确认其官方源码仓库地址,再从官方 Git 仓库的明确 tag/commit 拉取源码快照;禁止以 npm tarball、npm 缓存、`node_modules`、`dist/` 或其他预编译发布产物作为导入源。npm registry 只能用于核对包名、版本和发布元数据,不能替代源码仓库。
|
||||
- 源码导入必须保留上游源码、测试、必要文档、许可证和来源记录,移除嵌套 `.git`、上游本地 `.pi` 状态、`node_modules`、覆盖率与构建产物,并在本文件和扩展来源说明中记录官方仓库、tag、commit 与版本。
|
||||
- `pi-notify/` 的根依赖必须保持 `file:./pi-notify`,组合包直接加载 `pi-notify/src/index.ts`;不得同时安装或加载 npm 预编译入口,否则会重复通知。
|
||||
- 上游仅作为参考来源。同步上游改动时先核对本仓库已有修改,再按明确范围移植;不要直接覆盖本地实现。
|
||||
- `install.sh`、`update.sh`、`uninstall.sh` 和 `search_config.sh` 必须保持 POSIX `sh` 兼容和可执行权限,并包含在根 `package.json` 的 `files` 中;修改脚本行为时同步更新 README 和本文件。
|
||||
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
|
||||
- 安装流程中的机器级依赖和用户终端配置必须保持逐项询问且默认拒绝,不得在没有用户明确确认的情况下自动安装或改写。用户主动运行 `update.sh` 只授权升级已安装项和同步已有受管配置;Tool Search 完整旧默认快照迁移属于该受管同步,但必须先备份并保持 fail-safe。缺失项仍必须跳过。`.zshrc` 修改必须局限于受管块并保留备份,卸载不得顺带删除或还原共享工具和用户终端配置。
|
||||
- 未经明确要求,不执行发布、提交、推送、运行安装/卸载脚本或安装到用户 Pi 运行目录等外部写操作。
|
||||
|
||||
## 根目录 TypeScript 与 LSP 验证约定
|
||||
@@ -168,17 +181,38 @@
|
||||
- 修改该扩展后至少运行包内 `npm test`、`npm run check`、`npm run smoke` 与 `npm pack --dry-run --json`;加载或依赖入口变化还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
||||
|
||||
|
||||
## `pi-subagents` 开发约定
|
||||
|
||||
- 扩展入口是 `pi-subagents/src/index.ts`,测试保留在该目录的 `test/`,来源记录是 `pi-subagents/UPSTREAM.md`。
|
||||
- 同步上游必须从明确 tag/commit 移植并保留 README、CHANGELOG、SECURITY、作者和 LICENSE;不得导入嵌套 `.git`、上游 `.pi` 状态、`node_modules`、覆盖率或构建产物。
|
||||
- 根包必须通过 `file:./pi-subagents` 提供运行时依赖,并只通过 `extensions/subagents.ts` wrapper 直接加载仓库源码;不得绕过 wrapper 直接列出上游入口,不得同时安装或加载 npm 预编译入口,也不得把 Pi host runtime 作为组合包开发依赖带入根安装。
|
||||
- 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。
|
||||
- `extensions/subagents.ts` 是组合包配置入口:它只能把组合包内权威 `extensions/permission-system.ts` 的 canonical absolute path 作为 mandatory extension 注入。Agent frontmatter 的 `extensions: false`、`isolated` 和 `exclude_extensions` 均不得移除该 lifecycle;路径缺失、被同名其他路径冒充或 reload 后未精确命中必须在创建 AgentSession 前 fail-closed。
|
||||
- mandatory extension 只强制绑定 handlers,不自动开放它注册的工具;工具可见性仍由 Agent 的正常 extensions/tools 策略决定。mandatory 列表不得从 Agent Markdown 或项目配置读取。
|
||||
- `ask` 只能由 auto-review 在 delegation envelope 内明确 `allow` 时自动批准;reviewer `defer`、异常或不在 envelope 内必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。
|
||||
- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。
|
||||
- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。
|
||||
## `pi-notify` 开发约定
|
||||
|
||||
- 扩展入口是 `pi-notify/src/index.ts`,Kitty/macOS 后端位于 `pi-notify/src/terminal.ts`,测试保留在 `pi-notify/tests/`,来源记录是 `pi-notify/UPSTREAM.md`。
|
||||
- 同步上游必须从明确 tag/commit 移植,保留作者、MIT 许可声明与来源;不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
||||
- Kitty OSC 99 必须保留 source-window focus、Session 间唯一 ID、Base64 payload 和 tmux passthrough;修改 visibility/action、事件生命周期、fallback、模板、隐私或命令时必须同步 README 和测试。
|
||||
- 默认只在 TUI `agent_settled` 后通知,非 TUI 输出完整性、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
|
||||
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。
|
||||
|
||||
## `pi-ssh` 开发约定
|
||||
|
||||
- 扩展入口是 `pi-ssh/index.ts`,`ssh2` transport 与 SFTP 实现在 `pi-ssh/src/ssh2-transport.ts`,有界自适应远程搜索位于 `pi-ssh/src/remote-search.ts`,AES-GCM vault 位于 `pi-ssh/src/vault.ts`,选择性导入入口是根 `ssh_config.sh` 与 `pi-ssh/scripts/ssh-config.mjs`,权限桥接位于 `pi-ssh/permission-integration.ts`,测试保留在 `pi-ssh/test/`,来源记录是 `pi-ssh/UPSTREAM.md`。
|
||||
- 同步上游必须从明确 tag/commit 移植并保留 `LICENSE`、作者和来源;参考其他实现时记录明确 commit,不得导入嵌套 `.git`、`node_modules` 或构建产物。
|
||||
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass`、`SSH_ASKPASS` 或 ControlMaster;OpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。
|
||||
- 运行时必须保持纯 `ssh2`,不得回退到系统 OpenSSH、`sshpass`、`SSH_ASKPASS` 或 ControlMaster;认证可使用密码、私钥文件或 `ssh2` 直连的 SSH Agent socket(包括 1Password),Agent 模式只保存 socket 路径且不得导出私钥。OpenSSH 只允许由显式配置脚本通过 `ssh -G` 解析用户选中的 alias,已导入配置变化必须由用户显式更新。第一版遇到 `ProxyJump` / `ProxyCommand` 必须拒绝,不能静默忽略。
|
||||
- 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2` 与 `cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。
|
||||
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
|
||||
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
|
||||
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
|
||||
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated。RTK 不得处理这两个搜索工具的结果。
|
||||
- `ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持五个工具的默认策略为 `ask`。
|
||||
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、Agent 提供的签名材料、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
|
||||
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥认证只保存文件路径,Agent 认证只保存 socket 路径,均不得复制或导出私钥内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
|
||||
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
|
||||
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`。输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。
|
||||
- `ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持六个工具的默认策略为 `ask`。权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
|
||||
- SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd`、`export`、alias 或函数不得被伪装为持久状态。
|
||||
- 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。
|
||||
- `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。
|
||||
- RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read`、`ssh_find` 或 `ssh_grep` 返回。
|
||||
- SSH 工具的权限预览必须包含连接请求或当前远端的 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
|
||||
@@ -210,12 +244,14 @@
|
||||
|
||||
组合包依赖或加载入口变化时,至少验证根 `npm install` 幂等、锁文件有效、MCP adapter 唯一所有者不变量测试,以及全部扩展可在隔离的临时 Pi agent 目录加载。权限配置变化时使用仓库内固定快照的 `pi-permission-system` schema 校验,并验证包装入口部署后的文件与仓库源配置一致。
|
||||
|
||||
安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh`、`sh -n ssh_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。
|
||||
安装、升级、卸载、搜索或 SSH 配置脚本变化时,至少运行 `sh -n install.sh`、`sh -n update.sh`、`sh -n uninstall.sh`、`sh -n search_config.sh`、`sh -n ssh_config.sh` 和 ShellCheck,并核对脚本仍具有可执行权限、仍包含在根 `package.json` 的 `files` 中、README 描述与实际流程一致。Tool Search settings 迁移必须用隔离 agent dir 覆盖完整旧快照迁移、当前值幂等、自定义配置保留、无效 JSON fail-safe、备份/权限、`pi update` 失败不迁移和 `PI_CODING_AGENT_DIR`。搜索配置测试只能使用虚拟 key 和隔离 HOME;SSH 配置测试必须使用隔离 HOME、虚拟凭据与 mock transport,不得连接真实服务器或把真实密码写入测试输出。涉及真实 `pi install`、`pi update`、`pi remove`、Homebrew、远程安装器、Git 克隆、真实 SSH 连接或真实用户终端配置的端到端验证属于外部写操作,未经明确要求不得执行;可以使用隔离的临时 HOME 和 mock 命令验证分支行为。
|
||||
|
||||
Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。
|
||||
|
||||
在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。在 `pi-permission-auto-review/` 内至少运行同样三项;涉及权限集成时还要使用仓库内 `pi-permission-system` 验证 authorizer 注册、`allow` / `deny` / `defer` 与 delegation envelope。
|
||||
|
||||
在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
||||
|
||||
在 `pi-tool-search/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`;涉及加载入口或 active-tool 行为时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
|
||||
|
||||
|
||||
|
||||
@@ -14,13 +14,15 @@
|
||||
- `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。
|
||||
- `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。
|
||||
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`。
|
||||
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 后通知,并支持点击精确聚焦来源 Kitty 窗口。
|
||||
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
|
||||
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
|
||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
|
||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
|
||||
- `pi-chrome@0.15.46`:通过显式会话授权接入用户当前已登录的 Chrome Profile,并由 Tool Search 按导航观察、页面交互和调试上传三个工作流按需加载 21 个 `chrome_*` 工具。
|
||||
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
|
||||
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
|
||||
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、SFTP 与自适应有界搜索提供 `ssh_connect`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep`、`ssh_bash`;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||
- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。
|
||||
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
||||
|
||||
## 安装、升级与卸载
|
||||
@@ -85,9 +87,8 @@ Git checkout 在 `fetch` 后比较本地与上游提交,Homebrew 包在刷新
|
||||
日志中的“Zsh 插件未安装,升级流程跳过”不是升级失败:升级脚本不会新增用户此前未选择的
|
||||
插件。需要补装时重新运行 `./install.sh`,并在对应的逐项询问中明确选择 `Y`。
|
||||
|
||||
升级时会优先从 `pi update` 后的已安装组合包目录读取最新 `config/p10k.zsh`,再与受管副本比较,
|
||||
只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级
|
||||
已安装项并同步已受管配置。
|
||||
升级时会优先从 `pi update` 后的已安装组合包目录读取最新配置。对于 Pi agent 目录中的 `settings.json`,只有 `toolSearch` 完整匹配旧组合包生成的 `3` 组 / `20` 工具默认快照时,才会备份并原子迁移到当前 `5` / `28` 默认值;任何字段不同、结构无效或存在自定义工具时都不会被覆盖。Powerlevel10k 受管副本只在内容不同时备份并原子同步;`.zshrc` 也只更新已有的 my-pi 受管块,不触碰块外配置。因此安装流程负责补齐缺失项,升级流程只升级已安装项并同步可明确识别的受管配置。
|
||||
首次获得这项迁移能力时需要执行已经包含该逻辑的新版 `update.sh`;旧脚本正在运行时不会因为 `pi update` 替换包内容而自动获得后半段迁移步骤。如果刚通过旧脚本升级到含迁移逻辑的版本,可再运行一次更新后的脚本。
|
||||
|
||||
卸载组合包:
|
||||
|
||||
@@ -150,28 +151,35 @@ CodeGraph 本体不由组合包安装。需要使用 CodeGraph 的机器应自
|
||||
|
||||
本地源码直接使用 `@earendil-works/pi-coding-agent`、当前 `typebox`、`ModelRegistry.complete()` 与 `setActiveTools()`,不安装、别名映射或加载旧 `@mariozechner` runtime,也不使用 provider payload 改写、代理执行或隐藏 `sendMessage` 循环。
|
||||
|
||||
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics` 和 `tool_search`。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用或尚未授权而未注册的可选工具会自动从组中滤除,固定工具也不占动态组额度。
|
||||
新 session 默认常驻 Pi 核心 `read`、`write`、`edit`、`bash`、`grep`、`find`,以及 `codegraph_explore`、`lsp_diagnostics`、`ctx_execute`、`ctx_execute_file`、`ctx_batch_execute` 和 `tool_search`;Windows 上额外把内置 `powershell` 与 `bash` 作为同类本地 Shell 核心工具固定启用,非 Windows 平台则从目录中过滤它。组合包在 `pi-tool-search/extensions/bundle-groups.ts` 中为自身暴露的工具预置权威分组,包括本地文件导航、SSH 远端文件/命令、Chrome 导航/交互/调试、用户交互、CodeGraph/LSP、Tavily、Exa、Keenable、Context Mode 执行/知识库/观测/管理、Memory 查询/维护、Skill 和 MCP 管理。不可用、已固定或尚未授权而未注册的可选工具会自动从动态组中滤除。
|
||||
|
||||
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具时,才先为新增工具提供确定性分组,并可在第一次 `tool_search` 时使用当前已认证模型补充目录;模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会立即恢复预置目录,只有仍存在额外工具时才可能在下次搜索惰性增强。
|
||||
标准组合包的全部隐藏工具都能命中预置目录,因此首次使用不调用模型、不生成用户缓存,也不把完整隐藏 schema 发送给 provider。只有用户另外安装了未识别工具,并且当前调用不是精确加载已有分组时,才可使用当前已认证模型补充目录;精确已有分组始终立即按当前确定性目录激活,不等待无关未知工具的模型增强。模型结果必须保留组合包预置分组,否则直接拒绝。有效增强缓存以 `0600` 写到 agent 目录的 `tool-search/catalog-v1.json`,调用用量计入工具结果。用户 `groupOverrides` 的优先级高于预置目录;`/tool-search-rebuild` 会恢复预置目录,`/tool-search-status` 显示目录来源、容量、LRU 顺序和最近淘汰原因。
|
||||
|
||||
动态组默认最多同时加载 3 个、每个模型生成组最多 8 个工具、全部动态组最多 20 个工具。加载或调用组内工具会更新 LRU;加载第 4 组或超出工具总量时先卸载最久未使用的非固定组。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换不是纯增量,Pi 会自动走安全 fallback。
|
||||
动态组默认最多同时加载 5 个、每个模型生成组最多 8 个工具、全部动态组最多 28 个工具。`tool_search.groups` 可在一次调用中原子加载最多 5 个精确组;完整请求本身超限时保持原状态。加载或调用组内工具会更新 LRU,超限时先卸载最久未使用且不在本次请求内的组。自然语言 query 对精确工具名和已提交 workflow alias 加权,弱匹配或近似同分只返回候选。首次纯增加载继续使用 Pi 原生增量传播;发生卸载和加载的替换由 Pi 自动走安全 fallback。
|
||||
|
||||
首次加载仅在 `settings.json` 缺少对应字段时写入:
|
||||
Pi 的用户级配置文件通常是 `~/.pi/agent/settings.json`(准确位置为 Pi agent dir 下的 `settings.json`)。组合包首次加载仅在其中缺少对应字段时写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolSearch": {
|
||||
"alwaysEnabled": ["codegraph_explore", "lsp_diagnostics"],
|
||||
"alwaysEnabled": [
|
||||
"codegraph_explore",
|
||||
"lsp_diagnostics",
|
||||
"ctx_execute",
|
||||
"ctx_execute_file",
|
||||
"ctx_batch_execute"
|
||||
],
|
||||
"showToolSearchFooterStatus": false,
|
||||
"maxActiveGroups": 3,
|
||||
"maxActiveGroups": 5,
|
||||
"maxToolsPerGroup": 8,
|
||||
"maxDynamicTools": 20,
|
||||
"groupOverrides": {}
|
||||
"maxDynamicTools": 28,
|
||||
"groupOverrides": {},
|
||||
"bundleDefaultsVersion": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
用户已有配置不会被覆盖;结构无效时也不会重写原文件。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。
|
||||
扩展正常加载只补充缺失字段,不覆盖用户已有配置;首次创建完整 Tool Search 默认段时也写入 `bundleDefaultsVersion: 2`。用户显式运行新版 `update.sh` 时,脚本会在 `pi update` 成功后使用更新后组合包中的 Node helper 检查完整旧默认快照:只有精确匹配旧版 `3`/`20` 默认值才会创建权限 `600` 的时间戳备份并原子替换为当前配置;任何自定义或无效配置都会保留,提交前检测到文件内容变化也会中止。`alwaysEnabled` 中的精确工具名属于固定工具,不占动态组额度;`groupOverrides` 可把指定工具强制归入命名组。默认关闭状态行,避免与 minimal footer 叠加。
|
||||
|
||||
### 精确读写归 Hashline
|
||||
|
||||
@@ -209,11 +217,11 @@ FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash
|
||||
./ssh_config.sh import packaging-server
|
||||
```
|
||||
|
||||
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 Key 或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。邻接的随机 `vault.key` 用于 AES-256-GCM 解密;目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。
|
||||
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 SSH Agent(包括 1Password)、私钥文件或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。Agent 模式只保存 socket 路径,私钥仍留在 Agent 中;邻接的随机 `vault.key` 用于 AES-256-GCM 解密,目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。
|
||||
|
||||
可用 `./ssh_config.sh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md` 或 `CLAUDE.md`。连接不再通过 `/ssh`、`--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 调用 `ssh_connect`,连接动作及后续 `ssh_*` 操作均进入 `ask → auto-review`;会话结束自动断开。
|
||||
可用 `./ssh_config.sh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md` 或 `CLAUDE.md`。连接不再通过 `/ssh`、`--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 把受复核、串行且可取消的 `ssh_connect` 作为独立步骤调用并等待成功,再使用其他 `ssh_*` 工具;连接动作及后续操作均进入 `ask → auto-review`,相对远端路径会在预览中显示请求值和解析后的绝对路径,会话结束自动断开。SSH transport 在会话内持久,独立命令最多使用 4 路有界并发 exec channel,但每次 `ssh_bash` 都启动独立的非交互 Shell;需要持续改变后续远端操作的工作目录时,Agent 同样必须把受复核且串行执行的 `ssh_cd` 作为独立步骤调用并等待成功,而不是依赖某次 Shell 中临时执行的 `cd`。
|
||||
|
||||
远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find` 或 `rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find`、`ssh_grep` 或 `ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。
|
||||
远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find` 或 `rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。搜索目标作为绝对参数与当前远端执行 cwd 分离,因此 `ssh_grep` 可直接搜索单个文件而不会尝试把文件当目录进入;30 秒超时会报告解析后的 root 并提示先用 `ssh_find` 缩小范围。远端 HOME/cwd 通过可取消、有界随机标记探测获得,畸形结果不会更新连接状态。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find`、`ssh_grep` 或 `ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。
|
||||
|
||||
### 在线搜索服务
|
||||
|
||||
@@ -332,6 +340,19 @@ Hippo CLI:从升级后的组合包读取固定版本,先通过 npm 确认版
|
||||
`uninstall.sh` 移除组合包后询问是否一并执行 `npm uninstall -g hippo-memory`,默认保留;无论选择什么,
|
||||
`.hippo/` 和用户记忆数据都不会被脚本删除。
|
||||
|
||||
### Kitty 完成通知
|
||||
|
||||
`pi-notify/` 直接加载本地维护源码。它只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动
|
||||
compaction 和排队 follow-up 完成前不会提前通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session
|
||||
独立的稳定通知 ID、显式 `a=focus`,并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收
|
||||
键盘输入也会显示通知,点击通知由 Kitty 返回原始来源;仍可通过环境变量改用 `unfocused` 或 `invisible`。
|
||||
|
||||
默认忽略不足 3 秒的短任务。`/notify on|off|test|status` 可控制当前 Session;持久默认通过
|
||||
`PI_NOTIFY_ENABLED`、`PI_NOTIFY_MIN_SECONDS`、`PI_NOTIFY_VISIBILITY`、`PI_NOTIFY_ACTION`、
|
||||
`PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。`PI_NOTIFY_MESSAGE_SOURCE=none` 可避免把回复摘要发送到
|
||||
macOS 通知中心。Kitty 位于 tmux 内时需要 `set -g allow-passthrough all` 并重启 tmux server。
|
||||
完整配置和上游差异见 [`pi-notify/README.md`](pi-notify/README.md)。
|
||||
|
||||
### Codex 原生远程压缩
|
||||
|
||||
远程压缩只对 `openai-codex/openai-codex-responses` 生效,使用上游默认配置:在 turn boundary
|
||||
@@ -340,7 +361,9 @@ Hippo CLI:从升级后的组合包读取固定版本,先通过 npm 确认版
|
||||
|
||||
### Pi Condense
|
||||
|
||||
组合包首次加载时会在 `contextPrune.enabled` 尚未配置的情况下写入 `true`,因此 `pi-condense` 默认开启;用户通过 `/pruner off` 写入的显式 `false` 会被保留,后续启动或升级不会重新覆盖。它使用上游默认的 `agent-message` 触发模式:同一用户任务中的多轮工具调用会在最终文本回复后统一触发整理,默认仍按每个 assistant turn 分别生成摘要;后续上下文只保留摘要和 `t1`、`t2` 等引用。需要原文时,模型可调用 `context_tree_query({ toolCallIds: ["t1"] })` 恢复。
|
||||
组合包加载时会只为 `contextPrune` 中缺失的字段补入默认策略,任何用户显式值(包括 `/pruner off` 写入的 `enabled: false`)均优先且不会被覆盖。默认使用 `agent-message` 触发模式和 `turn` 批处理;正常上下文达到窗口的 73.5% 时中途整理,单轮增长达到窗口的 4% 时提前整理。对于 272K 上下文模型,两者约为 200K 和 11K tokens;其他模型按各自窗口比例计算。摘要使用当前默认模型和默认 thinking,连续 90 秒无输出才判定空闲超时,每次最长运行 5 分钟,并保留未压缩或摘要过大等跳过提示。
|
||||
|
||||
同一用户任务中的多轮工具调用通常在最终文本回复后触发整理,预算触发器则可在长任务中提前处理;后续上下文只保留摘要和 `t1`、`t2` 等引用。需要原文时,模型可调用 `context_tree_query({ toolCallIds: ["t1"] })` 恢复。
|
||||
|
||||
常用命令:`/pruner status` 查看状态,`/pruner settings` 调整配置,`/pruner now` 立即处理待总结批次,`/pruner tree` 浏览已归档调用,`/pruner stats` 查看累计成本和节省,`/pruner off` 关闭。配置保存在 Pi agent 目录 `settings.json` 的 `contextPrune` 字段。建议保留默认的 skill 路径保护,并把必须逐字复用输出的工具加入 `protectedTools`。
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"find": "allow",
|
||||
"multi_grep": "allow",
|
||||
"ssh_connect": "ask",
|
||||
"ssh_cd": "ask",
|
||||
"ssh_read": "ask",
|
||||
"ssh_write": "ask",
|
||||
"ssh_edit": "ask",
|
||||
|
||||
@@ -3,12 +3,25 @@ import { dirname, join } from "node:path";
|
||||
|
||||
export type CondenseDefaultResult = "updated" | "unchanged" | "skipped-invalid";
|
||||
|
||||
const CONDENSE_BUNDLE_DEFAULTS = {
|
||||
enabled: true,
|
||||
pruneOn: "agent-message",
|
||||
batchingMode: "turn",
|
||||
autoBudgetThreshold: 0.735,
|
||||
budgetTurnDelta: 0.04,
|
||||
summarizerModel: "default",
|
||||
summarizerThinking: "default",
|
||||
summarizerIdleTimeoutMs: 90_000,
|
||||
summarizerMaxTimeoutMs: 300_000,
|
||||
quietOversizedSkips: false,
|
||||
} as const;
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Enable pi-condense only when the user has not already chosen an enabled state. */
|
||||
export function ensureCondenseEnabledDefault(agentDir: string): CondenseDefaultResult {
|
||||
/** Fill missing pi-condense settings with bundle defaults while preserving every explicit user choice. */
|
||||
export function ensureCondenseDefaults(agentDir: string): CondenseDefaultResult {
|
||||
const targetPath = join(agentDir, "settings.json");
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
@@ -22,9 +35,12 @@ export function ensureCondenseEnabledDefault(agentDir: string): CondenseDefaultR
|
||||
|
||||
const existing = settings.contextPrune;
|
||||
if (existing !== undefined && !isObject(existing)) return "skipped-invalid";
|
||||
if (isObject(existing) && Object.hasOwn(existing, "enabled")) return "unchanged";
|
||||
|
||||
settings.contextPrune = { ...(existing ?? {}), enabled: true };
|
||||
const contextPrune = existing ?? {};
|
||||
const alreadyComplete = Object.keys(CONDENSE_BUNDLE_DEFAULTS).every((key) => Object.hasOwn(contextPrune, key));
|
||||
if (alreadyComplete) return "unchanged";
|
||||
|
||||
settings.contextPrune = { ...CONDENSE_BUNDLE_DEFAULTS, ...contextPrune };
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
const temporaryPath = `${targetPath}.my-pi.tmp`;
|
||||
writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { ensureCondenseEnabledDefault } from "./condense-config.ts";
|
||||
import { ensureCondenseDefaults } from "./condense-config.ts";
|
||||
|
||||
/** Apply the bundle default before pi-condense reads settings on session_start. */
|
||||
export default async function bundledCondenseExtension(pi: ExtensionAPI): Promise<void> {
|
||||
const result = ensureCondenseEnabledDefault(getAgentDir());
|
||||
const result = ensureCondenseDefaults(getAgentDir());
|
||||
if (result === "skipped-invalid") {
|
||||
console.warn("my-pi: skipped the pi-condense default because settings.json or contextPrune is invalid");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts";
|
||||
|
||||
// Bundle authority: Agent frontmatter may disable optional extensions, but it
|
||||
// cannot remove the exact wrapper that deploys my-pi's permission baseline and
|
||||
// binds the maintained permission-system source.
|
||||
const BUNDLED_PERMISSION_EXTENSION = fileURLToPath(
|
||||
new URL("./permission-system.ts", import.meta.url),
|
||||
);
|
||||
|
||||
export default createPiSubagentsExtension({
|
||||
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
|
||||
});
|
||||
@@ -46,7 +46,13 @@ export function buildToolRoutingSection(selectedTools: SelectedTools): string {
|
||||
|
||||
if (hasTool(selectedTools, "ssh_connect")) {
|
||||
rules.push(
|
||||
"- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Connect before calling other ssh_* tools; never infer or substitute a different host.",
|
||||
"- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTool(selectedTools, "ssh_cd")) {
|
||||
rules.push(
|
||||
"- Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Generated
+1986
-17
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
||||
"pi-extension-codex-fast-mode",
|
||||
"pi-rtk-optimizer",
|
||||
"pi-minimal-footer",
|
||||
"pi-subagents",
|
||||
"pi-notify",
|
||||
"pi-ssh",
|
||||
"pi-tool-search/extensions",
|
||||
"pi-tool-search/docs",
|
||||
@@ -58,9 +60,11 @@
|
||||
"./hippo-memory-pi/index.ts",
|
||||
"./node_modules/@ogulcancelik/pi-codex-compaction/index.ts",
|
||||
"./pi-minimal-footer/index.ts",
|
||||
"./pi-notify/src/index.ts",
|
||||
"./extensions/condense.ts",
|
||||
"./pi-permission-auto-review/index.ts",
|
||||
"./extensions/permission-system.ts",
|
||||
"./extensions/subagents.ts",
|
||||
"./pi-ssh/index.ts",
|
||||
"./pi-ask-user/index.ts",
|
||||
"./node_modules/pi-chrome/extensions/chrome-profile-bridge/index.ts",
|
||||
@@ -79,7 +83,9 @@
|
||||
"@keenable/pi-search": "0.1.2",
|
||||
"@ogulcancelik/pi-codex-compaction": "0.1.3",
|
||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||
"@smoose/pi-notify": "file:./pi-notify",
|
||||
"@tavily/pi-extension": "0.1.2",
|
||||
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
||||
"context-mode": "1.0.169",
|
||||
"jiti": "2.7.0",
|
||||
"pi-ask-user": "file:./pi-ask-user",
|
||||
|
||||
@@ -6,7 +6,7 @@ A locally maintained Pi extension that consolidates the upstream `question.ts` a
|
||||
|
||||
The extension registers `ask_user_question`. The model can present one to eight questions in one sequential TUI interaction:
|
||||
|
||||
- `select`: one to eight stable `{ value, label, description? }` choices, with an optional free-form choice;
|
||||
- `select`: one to eight stable `{ value, label, description? }` choices, always followed by a free-form choice that the model cannot disable;
|
||||
- `text`: a free-form Editor answer;
|
||||
- `required: false`: lets the user explicitly skip the question;
|
||||
- multiple questions: progress tabs plus a final review page.
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
|
||||
Initial reference snapshot: `dcd461925db2edf69a43c8135db1180d418afd54` (`main`, inspected 2026-08-24).
|
||||
|
||||
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
|
||||
The upstream `qna.ts` command is intentionally not included: this package only supports model-initiated questions. Local changes consolidate single and multi-question flows into one tool, add text questions, keep a user-controlled free-form escape hatch on every select question, validation, bounded schemas, cancellation/abort handling, width-aware rendering, and IME focus propagation.
|
||||
|
||||
The copied MIT license and original copyright notice are retained in `LICENSE`.
|
||||
|
||||
@@ -45,9 +45,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ
|
||||
throw new Error(`questions[${index}].type must be select or text`);
|
||||
}
|
||||
|
||||
if (question.allowCustom !== undefined && typeof question.allowCustom !== "boolean") {
|
||||
throw new Error(`questions[${index}].allowCustom must be a boolean`);
|
||||
}
|
||||
if (question.required !== undefined && typeof question.required !== "boolean") {
|
||||
throw new Error(`questions[${index}].required must be a boolean`);
|
||||
}
|
||||
@@ -77,7 +74,6 @@ export function normalizeQuestions(input: readonly QuestionInput[]): NormalizedQ
|
||||
prompt,
|
||||
type: question.type,
|
||||
options,
|
||||
allowCustom: question.type === "select" && question.allowCustom !== false,
|
||||
required: question.required !== false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -20,9 +20,8 @@ const QuestionSchema = Type.Object({
|
||||
type: Type.String({ enum: ["select", "text"], description: "select for choices; text for a free-form answer" }),
|
||||
options: Type.Optional(Type.Array(OptionSchema, {
|
||||
maxItems: MAX_OPTIONS,
|
||||
description: "Required and non-empty for select questions; omit for text questions",
|
||||
description: "Required and non-empty for select questions; omit for text questions. The tool always appends a free-form answer choice.",
|
||||
})),
|
||||
allowCustom: Type.Optional(Type.Boolean({ description: "For select questions, append a free-form answer choice (default: true)" })),
|
||||
required: Type.Optional(Type.Boolean({ description: "Whether the question must be answered (default: true); optional questions can be skipped" })),
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface QuestionInput {
|
||||
prompt: string;
|
||||
type: QuestionType;
|
||||
options?: QuestionOptionInput[];
|
||||
allowCustom?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,7 +21,6 @@ export interface NormalizedQuestion {
|
||||
prompt: string;
|
||||
type: QuestionType;
|
||||
options: QuestionOptionInput[];
|
||||
allowCustom: boolean;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export class AskUserView implements Component, Focusable {
|
||||
const question = this.currentQuestion();
|
||||
if (!question || question.type !== "select") return [];
|
||||
const options: RenderOption[] = question.options.map((option) => ({ ...option, kind: "option" }));
|
||||
if (question.allowCustom) options.push({ value: "", label: "Type something.", kind: "custom" });
|
||||
options.push({ value: "", label: "Type something.", kind: "custom" });
|
||||
if (!question.required) options.push({ value: "", label: "Skip this question.", kind: "skip" });
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { formatAnswers, normalizeQuestions, orderedAnswers } from "../src/normalize.ts";
|
||||
import type { UserAnswer } from "../src/types.ts";
|
||||
import type { QuestionInput, UserAnswer } from "../src/types.ts";
|
||||
|
||||
test("normalizes select and text questions with bounded defaults", () => {
|
||||
const questions = normalizeQuestions([
|
||||
@@ -27,14 +27,26 @@ test("normalizes select and text questions with bounded defaults", () => {
|
||||
prompt: "Choose a language",
|
||||
type: "select",
|
||||
options: [{ value: "ts", label: "TypeScript" }],
|
||||
allowCustom: true,
|
||||
required: true,
|
||||
});
|
||||
assert.equal(questions[1]?.label, "Q2");
|
||||
assert.equal(questions[1]?.allowCustom, false);
|
||||
assert.equal(questions[1]?.required, false);
|
||||
});
|
||||
|
||||
test("ignores the removed allowCustom input from legacy callers", () => {
|
||||
const legacyQuestion = {
|
||||
id: "language",
|
||||
prompt: "Choose a language",
|
||||
type: "select",
|
||||
options: [{ value: "ts", label: "TypeScript" }],
|
||||
allowCustom: false,
|
||||
} satisfies QuestionInput & { allowCustom: boolean };
|
||||
|
||||
const [question] = normalizeQuestions([legacyQuestion]);
|
||||
assert.ok(question);
|
||||
assert.equal("allowCustom" in question, false);
|
||||
});
|
||||
|
||||
test("rejects invalid ids, duplicate ids, invalid option combinations, and duplicate values", () => {
|
||||
assert.throws(() => normalizeQuestions([{ id: "bad id", prompt: "Bad", type: "text" }]), /must start with a letter/);
|
||||
assert.throws(() => normalizeQuestions([null as never]), /must be an object/);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 smoose
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,80 @@
|
||||
# pi-notify
|
||||
|
||||
Kitty-first completion notifications for Pi. This repository maintains a source import of
|
||||
`@smoose/pi-notify@0.1.1` with local reliability and multi-window improvements.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Waits for Pi's `agent_settled` event, so retries, automatic compaction, and queued follow-ups do not notify early.
|
||||
- Uses Kitty OSC 99 with Base64 payloads, an ID unique to each Pi session, and explicit `a=focus`.
|
||||
- Defaults to `o=always`: notifications remain visible while the originating Kitty window/pane has keyboard focus; clicking a notification returns to that exact source window.
|
||||
- Measures the entire run across automatic retries and ignores runs shorter than three seconds by default.
|
||||
- Emits terminal sequences only in interactive TUI mode, never into JSON, print, or RPC output.
|
||||
- Uses macOS AppleScript outside Kitty and a sanitized OSC 777 fallback outside macOS.
|
||||
|
||||
The root `my-pi` bundle loads this local source directly. For the unmodified upstream package, use:
|
||||
|
||||
```bash
|
||||
pi install npm:@smoose/pi-notify
|
||||
```
|
||||
|
||||
Do not separately install upstream when using the bundle, or notifications will be duplicated.
|
||||
|
||||
## Commands
|
||||
|
||||
- `/notify on`: enable notifications for the current session.
|
||||
- `/notify off`: disable notifications for the current session.
|
||||
- `/notify test`: send a notification immediately; click it to verify exact Kitty-window focus.
|
||||
- `/notify status`: show the active backend, visibility policy, and duration threshold.
|
||||
|
||||
Command overrides are session-local. Use environment variables for persistent defaults.
|
||||
|
||||
## Configuration
|
||||
|
||||
- `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled.
|
||||
- `PI_NOTIFY_TITLE`: title template; default `π - {project}`.
|
||||
- `PI_NOTIFY_BODY`: body template; default `{message}`.
|
||||
- `PI_NOTIFY_MESSAGE_SOURCE`: `assistant`, `user`, or privacy mode `none`; default `assistant`.
|
||||
- `PI_NOTIFY_MESSAGE_MAX`: maximum message characters before ellipsis; default `80`, maximum `500`.
|
||||
- `PI_NOTIFY_MIN_SECONDS`: minimum complete run duration; default `3`, range `0`–`3600`.
|
||||
- `PI_NOTIFY_VISIBILITY`: Kitty policy `unfocused`, `invisible`, or `always`; default `always`.
|
||||
- `PI_NOTIFY_ACTION`: Kitty click action `focus` or `none`; default `focus`.
|
||||
- `PI_NOTIFY_SOUND_CMD`: optional shell command run after the notification. This is trusted local configuration and executes through a shell.
|
||||
|
||||
Visibility policies:
|
||||
|
||||
- `unfocused`: notify whenever the originating Kitty window/pane does not have keyboard focus.
|
||||
- `invisible`: notify only when the originating window/pane is not visible.
|
||||
- `always`: notify even while typing in the originating window.
|
||||
|
||||
Inside tmux, enable passthrough and restart the tmux server after changing the setting:
|
||||
|
||||
```tmux
|
||||
set -g allow-passthrough all
|
||||
```
|
||||
|
||||
The active tmux client type is checked so a stale `KITTY_WINDOW_ID` cannot force the Kitty backend for a non-Kitty client.
|
||||
|
||||
## Template variables
|
||||
|
||||
- `{project}`: current directory name.
|
||||
- `{cwd}`: current working directory.
|
||||
- `{model}`: active model as `provider/id`.
|
||||
- `{model_short}`: active model ID.
|
||||
- `{message}`: selected message, or `Ready for input` when source is `none` or text is unavailable.
|
||||
- `{user_message}`: latest user text, truncated.
|
||||
- `{assistant_message}`: latest settled assistant text, truncated.
|
||||
- `{duration}` / `{duration_ms}`: complete duration including retries.
|
||||
- `{status}`: `ready`, or `ready · N tool errors` when tools reported errors.
|
||||
- `{status_icon}`: `✓` or `⚠`. This reports observed tool errors, not a definitive task-success verdict.
|
||||
- `{tool_error_count}`: observed tool error count.
|
||||
|
||||
Example for multiple projects without exposing assistant text on the lock screen:
|
||||
|
||||
```bash
|
||||
export PI_NOTIFY_TITLE='π {status_icon} · {project}'
|
||||
export PI_NOTIFY_MESSAGE_SOURCE=none
|
||||
export PI_NOTIFY_VISIBILITY=unfocused
|
||||
```
|
||||
|
||||
Kitty must be allowed under **System Settings → Notifications**. Self-built or unsigned Kitty binaries may not be able to publish macOS notifications.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Upstream source
|
||||
|
||||
- Project: `@smoose/pi-notify`
|
||||
- Repository: <https://github.com/smoosex/pi-notify>
|
||||
- Imported npm version: `0.1.1`
|
||||
- Imported commit: `3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`
|
||||
- Commit date: 2026-06-02
|
||||
- npm tarball and repository source were compared before import.
|
||||
|
||||
The source is maintained directly in this repository. It is not a submodule and does not retain an embedded `.git` directory or upstream build artifacts.
|
||||
|
||||
## Local changes
|
||||
|
||||
- Notify on `agent_settled` rather than `agent_end`, preserving elapsed time and the latest messages across retries.
|
||||
- Restrict terminal output to TUI mode so OSC sequences cannot corrupt RPC, JSON, or print output.
|
||||
- Give each Pi session a stable unique OSC 99 notification ID.
|
||||
- Make Kitty click-to-focus explicit and default visibility to `always`, while retaining `unfocused` and `invisible` as opt-in policies.
|
||||
- Add duration filtering, enable/privacy/action settings, status template fields, and `/notify` controls.
|
||||
- Keep Base64 Kitty payloads, harden OSC metadata/fallback sanitization, and ignore detached-process spawn errors.
|
||||
- Add lifecycle, multi-window metadata, fallback, parser, and configuration tests.
|
||||
|
||||
Upstream declares the project under the MIT license but did not include a `LICENSE` file at the imported commit or in the npm tarball. This directory adds the standard MIT text using the upstream author's GitHub identity and commit year so the declared license travels with the maintained source.
|
||||
Generated
+2007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@smoose/pi-notify",
|
||||
"version": "0.1.1-my-pi.1",
|
||||
"description": "Kitty-aware completion notifications for Pi with exact-window focus and settled-agent delivery.",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"notification"
|
||||
],
|
||||
"license": "MIT",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"src/",
|
||||
"README.md",
|
||||
"UPSTREAM.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/smoosex/pi-notify.git"
|
||||
},
|
||||
"homepage": "https://github.com/smoosex/pi-notify",
|
||||
"bugs": {
|
||||
"url": "https://github.com/smoosex/pi-notify/issues"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.84.2",
|
||||
"@types/node": "24.10.13",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "npm run typecheck && npm run test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { extractMessageText, truncateText } from "./messages.ts";
|
||||
import {
|
||||
parseEnabled,
|
||||
parseKittyVisibility,
|
||||
parseMessageMax,
|
||||
parseMessageSource,
|
||||
parseMinDurationMs,
|
||||
parseNotificationAction,
|
||||
} from "./parsers.ts";
|
||||
import { renderTemplate } from "./template.ts";
|
||||
import type { NotificationRuntimeValues, NotifyConfig } from "./types.ts";
|
||||
|
||||
const DEFAULT_TITLE = "π - {project}";
|
||||
const DEFAULT_BODY = "{message}";
|
||||
|
||||
export function getConfig(
|
||||
ctx: ExtensionContext | undefined,
|
||||
startedAt: number | undefined,
|
||||
messages: unknown[] | undefined,
|
||||
runtime: NotificationRuntimeValues,
|
||||
now = Date.now(),
|
||||
): NotifyConfig {
|
||||
const durationMs = startedAt === undefined ? 0 : Math.max(0, now - startedAt);
|
||||
const messageMax = parseMessageMax(process.env.PI_NOTIFY_MESSAGE_MAX);
|
||||
const userMessage = truncateText(extractMessageText(messages, "user"), messageMax);
|
||||
const assistantMessage = truncateText(extractMessageText(messages, "assistant"), messageMax);
|
||||
const messageSource = parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE);
|
||||
const selectedMessage = messageSource === "user" ? userMessage : messageSource === "assistant" ? assistantMessage : "";
|
||||
const message = selectedMessage || "Ready for input";
|
||||
const toolErrorCount = Math.max(0, runtime.toolErrorCount ?? 0);
|
||||
const values = {
|
||||
user_message: userMessage,
|
||||
assistant_message: assistantMessage,
|
||||
message,
|
||||
status: toolErrorCount > 0 ? `ready · ${toolErrorCount} tool error${toolErrorCount === 1 ? "" : "s"}` : "ready",
|
||||
status_icon: toolErrorCount > 0 ? "⚠" : "✓",
|
||||
tool_error_count: String(toolErrorCount),
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: parseEnabled(process.env.PI_NOTIFY_ENABLED),
|
||||
title: renderTemplate(process.env.PI_NOTIFY_TITLE || DEFAULT_TITLE, ctx, durationMs, values),
|
||||
body: renderTemplate(process.env.PI_NOTIFY_BODY || DEFAULT_BODY, ctx, durationMs, values),
|
||||
durationMs,
|
||||
minDurationMs: parseMinDurationMs(process.env.PI_NOTIFY_MIN_SECONDS),
|
||||
notificationId: runtime.notificationId,
|
||||
visibility: parseKittyVisibility(process.env.PI_NOTIFY_VISIBILITY),
|
||||
action: parseNotificationAction(process.env.PI_NOTIFY_ACTION),
|
||||
soundCommand: process.env.PI_NOTIFY_SOUND_CMD?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldNotify(config: NotifyConfig, enabledOverride?: boolean): boolean {
|
||||
return (enabledOverride ?? config.enabled) && config.durationMs >= config.minDurationMs;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { getConfig, shouldNotify } from "./config.ts";
|
||||
import { getNotificationBackend, sendNotification } from "./terminal.ts";
|
||||
import type { NotifyConfig } from "./types.ts";
|
||||
|
||||
type ExtensionDependencies = {
|
||||
now: () => number;
|
||||
createNotificationId: () => string;
|
||||
notify: (config: NotifyConfig) => void;
|
||||
getBackend: typeof getNotificationBackend;
|
||||
};
|
||||
|
||||
export function createPiNotifyExtension(dependencies: Partial<ExtensionDependencies> = {}) {
|
||||
const now = dependencies.now ?? Date.now;
|
||||
const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
|
||||
const notify = dependencies.notify ?? sendNotification;
|
||||
const getBackend = dependencies.getBackend ?? getNotificationBackend;
|
||||
|
||||
return function piNotify(pi: ExtensionAPI): void {
|
||||
const notificationId = createNotificationId();
|
||||
let startedAt: number | undefined;
|
||||
let latestMessages: unknown[] | undefined;
|
||||
let toolErrorCount = 0;
|
||||
let enabledOverride: boolean | undefined;
|
||||
|
||||
const resetRun = (): void => {
|
||||
startedAt = undefined;
|
||||
latestMessages = undefined;
|
||||
toolErrorCount = 0;
|
||||
};
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
startedAt ??= now();
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", (event) => {
|
||||
if (event.isError) toolErrorCount++;
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event) => {
|
||||
latestMessages = event.messages;
|
||||
});
|
||||
|
||||
pi.on("agent_settled", (_event, ctx) => {
|
||||
try {
|
||||
const config = getConfig(ctx, startedAt, latestMessages, { notificationId, toolErrorCount }, now());
|
||||
if (ctx.mode === "tui" && shouldNotify(config, enabledOverride)) notify(config);
|
||||
} finally {
|
||||
resetRun();
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("notify", {
|
||||
description: "Control Kitty/macOS completion notifications: on, off, test, status",
|
||||
handler: async (args, ctx) => {
|
||||
const action = args.trim().toLowerCase() || "status";
|
||||
|
||||
if (action === "on" || action === "off") {
|
||||
enabledOverride = action === "on";
|
||||
ctx.ui.notify(`Completion notifications ${action}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig(ctx, now() - 1000, undefined, { notificationId, toolErrorCount: 0 }, now());
|
||||
if (action === "test") {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("System notifications are only emitted in interactive TUI mode", "warning");
|
||||
return;
|
||||
}
|
||||
notify({ ...config, enabled: true, minDurationMs: 0, body: "Kitty notification test — click to focus this window" });
|
||||
ctx.ui.notify(`Test sent through ${getBackend()}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
const enabled = enabledOverride ?? config.enabled;
|
||||
ctx.ui.notify(
|
||||
`Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · min ${config.minDurationMs / 1000}s`,
|
||||
"info",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify("Usage: /notify on|off|test|status", "warning");
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default createPiNotifyExtension();
|
||||
@@ -0,0 +1,42 @@
|
||||
export function extractMessageText(messages: unknown[] | undefined, role: "user" | "assistant"): string {
|
||||
if (!messages) return "";
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (!isRecord(message) || message.role !== role) continue;
|
||||
|
||||
const text = normalizeMessageText(extractContentText(message.content));
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= maxLength) return text;
|
||||
if (maxLength <= 0) return "";
|
||||
if (maxLength === 1) return "…";
|
||||
return `${chars.slice(0, maxLength - 1).join("")}…`;
|
||||
}
|
||||
|
||||
function extractContentText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!isRecord(block)) return "";
|
||||
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function normalizeMessageText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { KittyVisibility, MessageSource, NotificationAction } from "./types.ts";
|
||||
|
||||
export function parseMessageSource(value: string | undefined): MessageSource {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "user" || normalized === "none") return normalized;
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
export function parseMessageMax(value: string | undefined): number {
|
||||
return parseBoundedInteger(value, 80, 0, 500);
|
||||
}
|
||||
|
||||
export function parseMinDurationMs(value: string | undefined): number {
|
||||
const seconds = parseBoundedNumber(value, 3, 0, 3600);
|
||||
return Math.round(seconds * 1000);
|
||||
}
|
||||
|
||||
export function parseEnabled(value: string | undefined): boolean {
|
||||
if (value === undefined) return true;
|
||||
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function parseKittyVisibility(value: string | undefined): KittyVisibility {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "always" || normalized === "unfocused" || normalized === "invisible") return normalized;
|
||||
return "always";
|
||||
}
|
||||
|
||||
export function parseNotificationAction(value: string | undefined): NotificationAction {
|
||||
return value?.trim().toLowerCase() === "none" ? "none" : "focus";
|
||||
}
|
||||
|
||||
function parseBoundedInteger(value: string | undefined, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number.parseInt(value || String(fallback), 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(parsed, max));
|
||||
}
|
||||
|
||||
function parseBoundedNumber(value: string | undefined, fallback: number, min: number, max: number): number {
|
||||
const normalized = value?.trim();
|
||||
const parsed = Number(normalized ? normalized : fallback);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(parsed, max));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { basename } from "node:path";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TemplateValues } from "./types.ts";
|
||||
|
||||
export function renderTemplate(
|
||||
template: string,
|
||||
ctx: ExtensionContext | undefined,
|
||||
durationMs: number,
|
||||
extraValues: TemplateValues = {},
|
||||
): string {
|
||||
const cwd = ctx?.cwd || process.cwd();
|
||||
const values: TemplateValues = {
|
||||
cwd,
|
||||
project: basename(cwd),
|
||||
model: formatModel(ctx),
|
||||
model_short: formatModelShort(ctx),
|
||||
duration: formatDuration(durationMs),
|
||||
duration_ms: String(durationMs),
|
||||
...extraValues,
|
||||
};
|
||||
|
||||
return template.replace(/\{([a-z_]+)\}/g, (match, key) => values[key] ?? match);
|
||||
}
|
||||
|
||||
function formatModel(ctx: ExtensionContext | undefined): string {
|
||||
if (!ctx?.model) return "unknown";
|
||||
return `${ctx.model.provider}/${ctx.model.id}`;
|
||||
}
|
||||
|
||||
function formatModelShort(ctx: ExtensionContext | undefined): string {
|
||||
return ctx?.model?.id || "unknown";
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const seconds = Math.round((ms % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import type { NotificationBackend, NotifyConfig } from "./types.ts";
|
||||
|
||||
const ESC = "\x1b";
|
||||
const BEL = "\x07";
|
||||
const ST = `${ESC}\\`;
|
||||
|
||||
type NotifierDependencies = {
|
||||
spawn: typeof spawn;
|
||||
readTmuxClientInfo: () => string | undefined;
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
|
||||
export function createNotifier(dependencies: Partial<NotifierDependencies> = {}) {
|
||||
const spawnCommand = dependencies.spawn ?? spawn;
|
||||
const tmuxClientInfo = dependencies.readTmuxClientInfo ?? readTmuxClientInfo;
|
||||
const platform = dependencies.platform ?? process.platform;
|
||||
|
||||
return {
|
||||
getBackend(): NotificationBackend {
|
||||
return detectNotificationBackend(tmuxClientInfo, platform);
|
||||
},
|
||||
sendNotification(config: NotifyConfig): void {
|
||||
try {
|
||||
const backend = detectNotificationBackend(tmuxClientInfo, platform);
|
||||
if (backend === "kitty") notifyKitty(config);
|
||||
else if (backend === "apple-script") notifyAppleScript(config.title, config.body, spawnCommand);
|
||||
else notifyOsc777(config.title, config.body);
|
||||
|
||||
runSoundHook(config.soundCommand, spawnCommand);
|
||||
} catch {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultNotifier = createNotifier();
|
||||
|
||||
export function sendNotification(config: NotifyConfig): void {
|
||||
defaultNotifier.sendNotification(config);
|
||||
}
|
||||
|
||||
export function getNotificationBackend(): NotificationBackend {
|
||||
return defaultNotifier.getBackend();
|
||||
}
|
||||
|
||||
export function detectNotificationBackend(
|
||||
readClientInfo: () => string | undefined = readTmuxClientInfo,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): NotificationBackend {
|
||||
if (isKitty(readClientInfo)) return "kitty";
|
||||
return platform === "darwin" ? "apple-script" : "osc-777";
|
||||
}
|
||||
|
||||
function isKitty(readClientInfo: () => string | undefined): boolean {
|
||||
if (!process.env.TMUX) return Boolean(process.env.KITTY_WINDOW_ID);
|
||||
|
||||
const info = readClientInfo()?.toLowerCase();
|
||||
if (info) return info.includes("kitty");
|
||||
return Boolean(process.env.KITTY_WINDOW_ID);
|
||||
}
|
||||
|
||||
function readTmuxClientInfo(): string | undefined {
|
||||
try {
|
||||
return execFileSync("tmux", ["display-message", "-p", "#{client_termname} #{client_termtype}"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyKitty(config: NotifyConfig): void {
|
||||
const safeTitle = Buffer.from(config.title, "utf8").toString("base64");
|
||||
const safeBody = Buffer.from(config.body, "utf8").toString("base64");
|
||||
const id = sanitizeMetadataValue(config.notificationId);
|
||||
writeSequence(
|
||||
`${ESC}]99;i=${id}:d=0:e=1:o=${config.visibility}:a=${config.action};${safeTitle}${ST}`,
|
||||
);
|
||||
writeSequence(`${ESC}]99;i=${id}:p=body:e=1;${safeBody}${ST}`);
|
||||
}
|
||||
|
||||
function notifyAppleScript(title: string, body: string, spawnCommand: typeof spawn): void {
|
||||
const script = `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`;
|
||||
spawnDetached(spawnCommand, "osascript", ["-e", script]);
|
||||
}
|
||||
|
||||
function notifyOsc777(title: string, body: string): void {
|
||||
process.stdout.write(`${ESC}]777;notify;${sanitizeOsc777(title)};${sanitizeOsc777(body)}${BEL}`);
|
||||
}
|
||||
|
||||
function writeSequence(sequence: string): void {
|
||||
process.stdout.write(wrapForTmux(sequence));
|
||||
}
|
||||
|
||||
function wrapForTmux(sequence: string): string {
|
||||
if (!process.env.TMUX) return sequence;
|
||||
return `${ESC}Ptmux;${sequence.replaceAll(ESC, ESC + ESC)}${ST}`;
|
||||
}
|
||||
|
||||
function sanitizeMetadataValue(value: string): string {
|
||||
const sanitized = value.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 128);
|
||||
return sanitized || `pi-${process.pid}`;
|
||||
}
|
||||
|
||||
function sanitizeOsc777(value: string): string {
|
||||
return value.replace(/[\x00-\x1f\x7f;]/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function appleScriptString(value: string): string {
|
||||
return `"${value.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"').trim()}"`;
|
||||
}
|
||||
|
||||
function runSoundHook(command: string | undefined, spawnCommand: typeof spawn): void {
|
||||
if (!command) return;
|
||||
spawnDetached(spawnCommand, command, [], true);
|
||||
}
|
||||
|
||||
function spawnDetached(spawnCommand: typeof spawn, command: string, args: string[], shell = false): void {
|
||||
const child = spawnCommand(command, args, {
|
||||
shell,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.on?.("error", () => {});
|
||||
child.unref();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type MessageSource = "user" | "assistant" | "none";
|
||||
|
||||
export type KittyVisibility = "always" | "unfocused" | "invisible";
|
||||
export type NotificationAction = "focus" | "none";
|
||||
export type NotificationBackend = "kitty" | "apple-script" | "osc-777";
|
||||
|
||||
export type NotifyConfig = {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
durationMs: number;
|
||||
minDurationMs: number;
|
||||
notificationId: string;
|
||||
visibility: KittyVisibility;
|
||||
action: NotificationAction;
|
||||
soundCommand?: string;
|
||||
};
|
||||
|
||||
export type NotificationRuntimeValues = {
|
||||
notificationId: string;
|
||||
toolErrorCount?: number;
|
||||
};
|
||||
|
||||
export type TemplateValues = Record<string, string>;
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { getConfig, shouldNotify } from "../src/config.ts";
|
||||
|
||||
const envKeys = [
|
||||
"PI_NOTIFY_ENABLED",
|
||||
"PI_NOTIFY_TITLE",
|
||||
"PI_NOTIFY_BODY",
|
||||
"PI_NOTIFY_MESSAGE_SOURCE",
|
||||
"PI_NOTIFY_MESSAGE_MAX",
|
||||
"PI_NOTIFY_MIN_SECONDS",
|
||||
"PI_NOTIFY_VISIBILITY",
|
||||
"PI_NOTIFY_ACTION",
|
||||
"PI_NOTIFY_SOUND_CMD",
|
||||
] as const;
|
||||
|
||||
const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = savedEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function context(): ExtensionContext {
|
||||
return {
|
||||
cwd: "/tmp/pi-notify",
|
||||
model: { provider: "openai", id: "gpt-4.1" },
|
||||
} as ExtensionContext;
|
||||
}
|
||||
|
||||
const runtime = { notificationId: "pi-session", toolErrorCount: 0 };
|
||||
|
||||
describe("config", () => {
|
||||
it("builds safe Kitty defaults from the settled assistant output", () => {
|
||||
const config = getConfig(
|
||||
context(),
|
||||
800,
|
||||
[
|
||||
{ role: "user", content: "please fix this" },
|
||||
{ role: "assistant", content: "fixed it" },
|
||||
],
|
||||
runtime,
|
||||
5_000,
|
||||
);
|
||||
|
||||
assert.deepEqual(config, {
|
||||
enabled: true,
|
||||
title: "π - pi-notify",
|
||||
body: "fixed it",
|
||||
durationMs: 4_200,
|
||||
minDurationMs: 3_000,
|
||||
notificationId: "pi-session",
|
||||
visibility: "always",
|
||||
action: "focus",
|
||||
soundCommand: undefined,
|
||||
});
|
||||
assert.equal(shouldNotify(config), true);
|
||||
});
|
||||
|
||||
it("supports privacy, threshold, focus, and status template settings", () => {
|
||||
process.env.PI_NOTIFY_ENABLED = "off";
|
||||
process.env.PI_NOTIFY_TITLE = "{status_icon} {status} {tool_error_count}";
|
||||
process.env.PI_NOTIFY_BODY = "{message}";
|
||||
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
|
||||
process.env.PI_NOTIFY_MIN_SECONDS = "10.5";
|
||||
process.env.PI_NOTIFY_VISIBILITY = "invisible";
|
||||
process.env.PI_NOTIFY_ACTION = "none";
|
||||
process.env.PI_NOTIFY_SOUND_CMD = " say done ";
|
||||
|
||||
const config = getConfig(context(), 1_000, [{ role: "assistant", content: "secret" }], {
|
||||
notificationId: "pi-session",
|
||||
toolErrorCount: 2,
|
||||
}, 2_000);
|
||||
|
||||
assert.equal(config.title, "⚠ ready · 2 tool errors 2");
|
||||
assert.equal(config.body, "Ready for input");
|
||||
assert.equal(config.enabled, false);
|
||||
assert.equal(config.minDurationMs, 10_500);
|
||||
assert.equal(config.visibility, "invisible");
|
||||
assert.equal(config.action, "none");
|
||||
assert.equal(config.soundCommand, "say done");
|
||||
assert.equal(shouldNotify(config), false);
|
||||
assert.equal(shouldNotify(config, true), false, "the minimum duration still applies to command overrides");
|
||||
});
|
||||
|
||||
it("applies selected-message truncation and a session override", () => {
|
||||
process.env.PI_NOTIFY_MESSAGE_SOURCE = "user";
|
||||
process.env.PI_NOTIFY_MESSAGE_MAX = "5";
|
||||
process.env.PI_NOTIFY_MIN_SECONDS = "0";
|
||||
const config = getConfig(
|
||||
context(),
|
||||
1_000,
|
||||
[{ role: "user", content: "hello world" }],
|
||||
runtime,
|
||||
2_000,
|
||||
);
|
||||
|
||||
assert.equal(config.body, "hell…");
|
||||
assert.equal(shouldNotify(config, false), false);
|
||||
assert.equal(shouldNotify(config, true), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { createPiNotifyExtension } from "../src/index.ts";
|
||||
import type { NotifyConfig } from "../src/types.ts";
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const savedMinSeconds = process.env.PI_NOTIFY_MIN_SECONDS;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedMinSeconds === undefined) delete process.env.PI_NOTIFY_MIN_SECONDS;
|
||||
else process.env.PI_NOTIFY_MIN_SECONDS = savedMinSeconds;
|
||||
});
|
||||
|
||||
function harness(mode: ExtensionContext["mode"] = "tui") {
|
||||
const handlers = new Map<string, Handler>();
|
||||
const commands = new Map<string, { handler: Handler }>();
|
||||
const notifications: NotifyConfig[] = [];
|
||||
const uiMessages: string[] = [];
|
||||
let now = 1_000;
|
||||
|
||||
const pi = {
|
||||
on(name: string, handler: Handler) {
|
||||
handlers.set(name, handler);
|
||||
},
|
||||
registerCommand(name: string, command: { handler: Handler }) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
createPiNotifyExtension({
|
||||
now: () => now,
|
||||
createNotificationId: () => "pi-session-test",
|
||||
notify: (config) => notifications.push(config),
|
||||
getBackend: () => "kitty",
|
||||
})(pi);
|
||||
|
||||
const ctx = {
|
||||
mode,
|
||||
hasUI: mode === "tui" || mode === "rpc",
|
||||
cwd: "/tmp/project-a",
|
||||
model: { provider: "openai", id: "gpt-test" },
|
||||
ui: {
|
||||
notify(message: string) {
|
||||
uiMessages.push(message);
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
|
||||
return {
|
||||
handlers,
|
||||
commands,
|
||||
notifications,
|
||||
uiMessages,
|
||||
ctx,
|
||||
setNow(value: number) {
|
||||
now = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("pi-notify extension", () => {
|
||||
it("notifies once only after retries and follow-ups have settled", async () => {
|
||||
const h = harness();
|
||||
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||
h.setNow(2_000);
|
||||
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "retrying" }] }, h.ctx);
|
||||
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||
await h.handlers.get("tool_execution_end")?.({ isError: true }, h.ctx);
|
||||
h.setNow(5_000);
|
||||
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "finally done" }] }, h.ctx);
|
||||
|
||||
assert.equal(h.notifications.length, 0, "agent_end must not notify before Pi is settled");
|
||||
await h.handlers.get("agent_settled")?.({}, h.ctx);
|
||||
|
||||
assert.equal(h.notifications.length, 1);
|
||||
assert.equal(h.notifications[0]?.body, "finally done");
|
||||
assert.equal(h.notifications[0]?.durationMs, 4_000, "duration includes retries");
|
||||
assert.equal(h.notifications[0]?.notificationId, "pi-session-test");
|
||||
});
|
||||
|
||||
it("does not write terminal notifications in RPC or non-interactive modes", async () => {
|
||||
process.env.PI_NOTIFY_MIN_SECONDS = "0";
|
||||
for (const mode of ["rpc", "json", "print"] as const) {
|
||||
const h = harness(mode);
|
||||
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||
await h.handlers.get("agent_settled")?.({}, h.ctx);
|
||||
assert.equal(h.notifications.length, 0, mode);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers session controls and a test notification", async () => {
|
||||
const h = harness();
|
||||
const command = h.commands.get("notify");
|
||||
assert.ok(command);
|
||||
|
||||
await command.handler("off", h.ctx);
|
||||
assert.match(h.uiMessages.at(-1) ?? "", /off/);
|
||||
await command.handler("test", h.ctx);
|
||||
assert.equal(h.notifications.length, 1);
|
||||
assert.match(h.notifications[0]?.body ?? "", /click to focus this window/);
|
||||
await command.handler("status", h.ctx);
|
||||
assert.match(h.uiMessages.at(-1) ?? "", /kitty/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { extractMessageText, truncateText } from "../src/messages.ts";
|
||||
|
||||
describe("messages", () => {
|
||||
it("extracts the latest text message for a role", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "first user" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "first assistant" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "latest\nuser" }] },
|
||||
];
|
||||
|
||||
assert.equal(extractMessageText(messages, "user"), "latest user");
|
||||
assert.equal(extractMessageText(messages, "assistant"), "first assistant");
|
||||
});
|
||||
|
||||
it("ignores non-text content blocks", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "image", text: "ignore me" },
|
||||
{ type: "text", text: "keep me" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(extractMessageText(messages, "assistant"), "keep me");
|
||||
});
|
||||
|
||||
it("truncates by unicode code points", () => {
|
||||
assert.equal(truncateText("hello", 10), "hello");
|
||||
assert.equal(truncateText("hello", 4), "hel…");
|
||||
assert.equal(truncateText("🤖abc", 3), "🤖a…");
|
||||
assert.equal(truncateText("hello", 1), "…");
|
||||
assert.equal(truncateText("hello", 0), "");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
parseEnabled,
|
||||
parseKittyVisibility,
|
||||
parseMessageMax,
|
||||
parseMessageSource,
|
||||
parseMinDurationMs,
|
||||
parseNotificationAction,
|
||||
} from "../src/parsers.ts";
|
||||
|
||||
describe("parsers", () => {
|
||||
it("parses message source including privacy mode", () => {
|
||||
assert.equal(parseMessageSource("assistant"), "assistant");
|
||||
assert.equal(parseMessageSource(" user "), "user");
|
||||
assert.equal(parseMessageSource("none"), "none");
|
||||
assert.equal(parseMessageSource("invalid"), "assistant");
|
||||
});
|
||||
|
||||
it("parses and clamps numeric settings", () => {
|
||||
assert.equal(parseMessageMax(undefined), 80);
|
||||
assert.equal(parseMessageMax("12"), 12);
|
||||
assert.equal(parseMessageMax("-1"), 0);
|
||||
assert.equal(parseMessageMax("999"), 500);
|
||||
assert.equal(parseMessageMax("nope"), 80);
|
||||
assert.equal(parseMinDurationMs(undefined), 3_000);
|
||||
assert.equal(parseMinDurationMs(" "), 3_000);
|
||||
assert.equal(parseMinDurationMs("1.25"), 1_250);
|
||||
assert.equal(parseMinDurationMs("9999"), 3_600_000);
|
||||
});
|
||||
|
||||
it("uses fail-safe defaults for boolean and Kitty behavior settings", () => {
|
||||
assert.equal(parseEnabled(undefined), true);
|
||||
assert.equal(parseEnabled("off"), false);
|
||||
assert.equal(parseEnabled("yes"), true);
|
||||
assert.equal(parseKittyVisibility(undefined), "always");
|
||||
assert.equal(parseKittyVisibility("always"), "always");
|
||||
assert.equal(parseKittyVisibility("unfocused"), "unfocused");
|
||||
assert.equal(parseKittyVisibility("invisible"), "invisible");
|
||||
assert.equal(parseKittyVisibility("invalid"), "always");
|
||||
assert.equal(parseNotificationAction("none"), "none");
|
||||
assert.equal(parseNotificationAction("invalid"), "focus");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { renderTemplate } from "../src/template.ts";
|
||||
|
||||
function context(): ExtensionContext {
|
||||
return {
|
||||
cwd: "/tmp/pi-notify",
|
||||
model: { provider: "ollama", id: "qwen3" },
|
||||
} as ExtensionContext;
|
||||
}
|
||||
|
||||
describe("template", () => {
|
||||
it("renders built-in values and keeps unknown placeholders", () => {
|
||||
const output = renderTemplate(
|
||||
"{project} {cwd} {model} {model_short} {duration} {duration_ms} {missing}",
|
||||
context(),
|
||||
4200,
|
||||
);
|
||||
|
||||
assert.equal(output, "/tmp/pi-notify".split("/").at(-1) + " /tmp/pi-notify ollama/qwen3 qwen3 4.2s 4200 {missing}");
|
||||
});
|
||||
|
||||
it("allows extra values to override built-ins", () => {
|
||||
assert.equal(renderTemplate("{project} {message}", context(), 0, { project: "custom", message: "done" }), "custom done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import { createNotifier, detectNotificationBackend } from "../src/terminal.ts";
|
||||
import type { NotifyConfig } from "../src/types.ts";
|
||||
|
||||
const ESC = "\x1b";
|
||||
const ST = `${ESC}\\`;
|
||||
const savedEnv = {
|
||||
KITTY_WINDOW_ID: process.env.KITTY_WINDOW_ID,
|
||||
TMUX: process.env.TMUX,
|
||||
};
|
||||
|
||||
let originalWrite: typeof process.stdout.write;
|
||||
let output = "";
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.KITTY_WINDOW_ID;
|
||||
delete process.env.TMUX;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdout.write = originalWrite;
|
||||
output = "";
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function captureStdout(): void {
|
||||
originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
output += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
}
|
||||
|
||||
function config(title = "Title", body = "Body"): NotifyConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
title,
|
||||
body,
|
||||
durationMs: 5_000,
|
||||
minDurationMs: 3_000,
|
||||
notificationId: "pi-session:unsafe",
|
||||
visibility: "unfocused",
|
||||
action: "focus",
|
||||
};
|
||||
}
|
||||
|
||||
function captureSpawn(): { calls: unknown[][]; spawn: typeof import("node:child_process").spawn } {
|
||||
const calls: unknown[][] = [];
|
||||
const spawn = ((...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
return { on() {}, unref() {} };
|
||||
}) as unknown as typeof import("node:child_process").spawn;
|
||||
return { calls, spawn };
|
||||
}
|
||||
|
||||
describe("terminal notifications", () => {
|
||||
it("writes encoded Kitty OSC 99 payloads with exact-window focus metadata", () => {
|
||||
process.env.KITTY_WINDOW_ID = "1";
|
||||
const notifier = createNotifier();
|
||||
captureStdout();
|
||||
notifier.sendNotification(config("π - pi-notify", "done\nnow"));
|
||||
|
||||
const title = Buffer.from("π - pi-notify", "utf8").toString("base64");
|
||||
const body = Buffer.from("done\nnow", "utf8").toString("base64");
|
||||
assert.equal(
|
||||
output,
|
||||
`${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}` +
|
||||
`${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps Kitty OSC 99 sequences for tmux passthrough", () => {
|
||||
process.env.TMUX = "/tmp/tmux";
|
||||
const notifier = createNotifier({ readTmuxClientInfo: () => "xterm-kitty xterm-kitty" });
|
||||
captureStdout();
|
||||
notifier.sendNotification(config("Pi", "done"));
|
||||
|
||||
const title = Buffer.from("Pi", "utf8").toString("base64");
|
||||
const body = Buffer.from("done", "utf8").toString("base64");
|
||||
const titleSequence = `${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}`.replaceAll(ESC, ESC + ESC);
|
||||
const bodySequence = `${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`.replaceAll(ESC, ESC + ESC);
|
||||
assert.equal(output, `${ESC}Ptmux;${titleSequence}${ST}${ESC}Ptmux;${bodySequence}${ST}`);
|
||||
});
|
||||
|
||||
it("uses AppleScript safely on macOS when Kitty is not detected", () => {
|
||||
const { calls, spawn } = captureSpawn();
|
||||
const notifier = createNotifier({ spawn, platform: "darwin" });
|
||||
captureStdout();
|
||||
notifier.sendNotification(config('Pi "notify"', "done\\now"));
|
||||
|
||||
assert.equal(output, "");
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
"osascript",
|
||||
["-e", 'display notification "done\\\\now" with title "Pi \\"notify\\""'],
|
||||
{ shell: false, detached: true, stdio: "ignore" },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let a stale Kitty variable override the active tmux client", () => {
|
||||
process.env.TMUX = "/tmp/tmux";
|
||||
process.env.KITTY_WINDOW_ID = "stale";
|
||||
assert.equal(
|
||||
detectNotificationBackend(() => "xterm-ghostty ghostty", "darwin"),
|
||||
"apple-script",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a sanitized OSC 777 fallback outside macOS", () => {
|
||||
const notifier = createNotifier({ platform: "linux" });
|
||||
captureStdout();
|
||||
notifier.sendNotification(config("Pi;bad", "done\u0007now"));
|
||||
assert.equal(output, `${ESC}]777;notify;Pi bad;done now\x07`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -24,7 +24,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
||||
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
|
||||
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
||||
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
|
||||
- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
||||
- **Native in-process subagent integration** — [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) and the source-maintained `my-pi` fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) register child sessions automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -428,14 +428,14 @@ This requires two detections:
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| pi-agent-router (original) | `PI_IS_SUBAGENT`, `PI_SUBAGENT_SESSION_ID`, `PI_AGENT_ROUTER_SUBAGENT` | `PI_AGENT_ROUTER_PARENT_SESSION_ID` |
|
||||
| [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_RUN_ID`, `PI_SUBAGENT_CHILD_AGENT`, `PI_SUBAGENT_DEPTH` | none set (see #98) |
|
||||
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | none - runs fully in-process via `createAgentSession()` | n/a - deferred to #29 |
|
||||
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | none - runs fully in-process via `createAgentSession()`; the maintained `my-pi` fork uses the registry lifecycle | n/a |
|
||||
| [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | `PI_SUBAGENT_NAME`, `PI_SUBAGENT_ID`, `PI_SUBAGENT_SESSION`, `PI_SUBAGENT_ACTIVITY_FILE` | none set (see #98) |
|
||||
|
||||
### Detection (`isSubagentExecutionContext`)
|
||||
|
||||
`isSubagentExecutionContext()` checks three sources in priority order:
|
||||
|
||||
1. **Explicit registry** - `@gotgenes/pi-subagents` emits `subagents:child:session-created` before `bindExtensions()`; the permission system's subscriber writes the entry into `SubagentSessionRegistry` synchronously.
|
||||
1. **Explicit registry** - a compatible in-process spawner (`@gotgenes/pi-subagents` or the maintained `my-pi` Tintinweb fork) emits `subagents:child:session-created` before `bindExtensions()`; the permission system's subscriber writes the entry into `SubagentSessionRegistry` synchronously.
|
||||
The registry (keyed by **child session id**) is checked first.
|
||||
Each concurrent sibling child of the same parent receives a unique session id from `sessionManager.newSession()`, so siblings occupy distinct keys - one sibling's `disposed` event cannot evict another's entry (fixes #298).
|
||||
The registry is a process-global singleton (via `getSubagentSessionRegistry()`, backed by `globalThis` + `Symbol.for()`) because each session's `ResourceLoader` creates its own `pi.events` bus: the parent's instance registers the child over the parent bus, while the child's separate jiti instance reads the same global store to detect itself and resolve its forwarding target.
|
||||
@@ -457,8 +457,8 @@ Adding a new env var candidate when an extension adopts the convention is a one-
|
||||
|
||||
### In-process case (resolved)
|
||||
|
||||
In-process subagent extensions (e.g. `@gotgenes/pi-subagents`) call `createAgentSession()` directly - no child process is spawned and no env vars are ever set.
|
||||
`@gotgenes/pi-subagents` publishes `subagents:child:session-created` (before `bindExtensions()`) and `subagents:child:disposed` (in the run's `finally`); `src/authority/subagent-lifecycle-events.ts` subscribes and writes/removes the entry in `SubagentSessionRegistry` synchronously.
|
||||
In-process subagent extensions call `createAgentSession()` directly - no child process is spawned and no env vars are ever set.
|
||||
`@gotgenes/pi-subagents` and the maintained `my-pi` Tintinweb fork publish `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` after the child closes; `src/authority/subagent-lifecycle-events.ts` subscribes and writes/removes the entry in `SubagentSessionRegistry` synchronously.
|
||||
The registry is process-global (see `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`) so the child's separate jiti instance reads the same store as the parent.
|
||||
See `src/authority/subagent-registry.ts` and [Subagent Integration](../subagent-integration.md) for details.
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ The extractor must not throw — guard your parsing and return `undefined` on an
|
||||
#### Subagent session registration
|
||||
|
||||
In-process subagent registration is event-driven.
|
||||
`@gotgenes/pi-subagents` emits `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` in the run's `finally`; the permission system subscribes automatically — no service call from the spawner is required.
|
||||
A compatible in-process spawner emits `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` after the child closes; the permission system subscribes automatically — no service call from the spawner is required. Current publishers are `@gotgenes/pi-subagents` and the source-maintained `my-pi` fork of `tintinweb/pi-subagents`.
|
||||
See [Subagent Integration](subagent-integration.md) for details.
|
||||
|
||||
### Reload Safety
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Subagent Integration
|
||||
|
||||
## Native integration with `@gotgenes/pi-subagents`
|
||||
## Native in-process integration
|
||||
|
||||
[`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) is the only subagent extension with native permission-system integration.
|
||||
It publishes a child-execution lifecycle on `pi.events`; this package subscribes (see `src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`.
|
||||
[`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) and the `my-pi` maintained fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) publish the same child-execution lifecycle on `pi.events`. This package subscribes (see `src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session with the `SubagentSessionRegistry` on the `subagents:child:session-created` event — emitted before `bindExtensions()` fires — and unregisters it on `subagents:child:disposed`.
|
||||
Because the event bus dispatches synchronously, the synchronous registration completes before binding proceeds.
|
||||
This inverts the former dependency direction: the core no longer looks up this package's service ([ADR-0002] / pi-subagents [#261]).
|
||||
|
||||
@@ -19,8 +18,10 @@ The integration enables:
|
||||
The parent approves or denies, and the child resumes.
|
||||
When the parent approves "for this session," it chooses a scope: **this subagent only** (the least-privilege default) records the grant on the requesting child, while **the whole session** records it on the serving parent so the parent and all its subagents resolve it without re-prompting.
|
||||
|
||||
No configuration is required - the integration is automatic when both extensions are installed.
|
||||
When `@gotgenes/pi-permission-system` is not installed, `@gotgenes/pi-subagents` emits its lifecycle events with no subscriber - a harmless no-op.
|
||||
No configuration is required - the integration is automatic when the permission system and a lifecycle-compatible subagent extension are installed.
|
||||
When the permission system is not installed, lifecycle events have no subscriber and are a harmless no-op.
|
||||
|
||||
Lifecycle registration alone is not a tool-call gate. A host that requires permission enforcement must also make the permission extension itself mandatory in every child loader. The maintained `my-pi` Tintinweb fork receives the exact bundle-owned `extensions/permission-system.ts` path from its host wrapper; Agent `extensions: false`, `isolated`, and `exclude_extensions` policy cannot remove it, and a missing or substituted entry aborts child startup. Mandatory binding does not automatically expose extension tools.
|
||||
|
||||
## Permission Forwarding
|
||||
|
||||
@@ -116,7 +117,8 @@ These compose correctly with the permission system because the two operate at di
|
||||
| Extension | Type | Permission integration | Frontmatter key |
|
||||
| ----------------------------------------------------------------------------------- | ---------- | -------------------------------- | ---------------------------------- |
|
||||
| [@gotgenes/pi-subagents](https://github.com/gotgenes/pi-subagents) | in-process | ✓ Native (registry + forwarding) | `disallowed_tools:` (CSV denylist) |
|
||||
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✗ No registration | `disallowed_tools:` (CSV denylist) |
|
||||
| my-pi maintained [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✓ Native (registry + forwarding) | `disallowed_tools:` (CSV denylist) |
|
||||
| upstream [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✗ No registration | `disallowed_tools:` (CSV denylist) |
|
||||
| [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | subprocess | ✗ Missing env vars | `tools:` (CSV allowlist) |
|
||||
| [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | subprocess | ✗ Missing env vars | `deny-tools:` (CSV denylist) |
|
||||
|
||||
@@ -124,7 +126,7 @@ Process-based subagent extensions (nicobailon, HazAT) spawn child processes but
|
||||
Without that env var, `ask` permissions in child processes are auto-denied.
|
||||
See [guides/permission-frontmatter-for-subagent-extensions.md](guides/permission-frontmatter-for-subagent-extensions.md) for the convention that subagent extension authors should follow.
|
||||
|
||||
The upstream `tintinweb/pi-subagents` (which `@gotgenes/pi-subagents` forks) does not publish the `subagents:child:session-created` lifecycle event, so it lacks deterministic child detection and `ask`-state forwarding.
|
||||
The upstream `tintinweb/pi-subagents` does not currently publish this lifecycle. The source-maintained `my-pi` snapshot adds the same event contract without importing permission-system internals; this row must not be read as compatibility for the unmodified npm/GitHub release.
|
||||
|
||||
### Interaction Rules
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Keep the SSH transport persistent while making `ssh_bash` explicitly stateless: every call starts in the active remote cwd and local Pi cwd/environment metadata can no longer leak into remote execution.
|
||||
- Add reviewed, sequential `ssh_cd` workspace changes for persistent remote cwd selection without a hidden interactive shell; dependent remote calls are explicitly deferred until the change succeeds.
|
||||
- Clarify remote shell prompt metadata and add regression coverage for cwd isolation, atomic workspace changes, failure preservation, and subsequent shell/file/search path resolution.
|
||||
- Make `ssh_connect` a sequential reviewed state transition so establishing or replacing the active connection cannot overlap dependent remote operations.
|
||||
- Separate remote-search execution cwd from its absolute target root, including regression coverage for single-file `ssh_grep` targets.
|
||||
- Resolve relative and `~/` remote paths to absolute targets in permission previews while retaining the original request and excluding remote paths from local filesystem gates.
|
||||
- Replace unbounded HOME/cwd capture with cancellable, bounded, random-marker probes that accept one absolute POSIX path and preserve prior state on failure.
|
||||
- Allow up to four independent SSH exec channels while retaining serialized SFTP operations; queued exec cancellation is immediate.
|
||||
- Report bounded imported host ID alternatives for unknown IDs and add actionable resolved-root guidance for 30-second remote-search timeouts.
|
||||
- Normalize `ssh_grep` hidden-file, basename-glob, and portable ERE behavior across ripgrep, Git, and fallback backends; reject `~user` paths.
|
||||
- Treat fallback grep exit 1 as a genuine no-match without hiding find or grep errors, separate backend stderr, and use bounded NUL-delimited grep records for newline-safe filenames.
|
||||
|
||||
## 0.9.0 - 2026-08-21
|
||||
|
||||
- Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts.
|
||||
|
||||
+24
-14
@@ -3,6 +3,7 @@
|
||||
`pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection:
|
||||
|
||||
- `ssh_connect`
|
||||
- `ssh_cd`
|
||||
- `ssh_read`
|
||||
- `ssh_write`
|
||||
- `ssh_edit`
|
||||
@@ -14,7 +15,7 @@ The extension does not override Pi's local `read`, `write`, `edit`, `find`, `gre
|
||||
|
||||
## Architecture
|
||||
|
||||
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Remote file operations use SFTP and remote shell commands use an SSH exec channel.
|
||||
Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and does not require `sshpass`, `ControlMaster`, or passwordless login. Authentication can use a private-key file, a password, or an SSH agent socket such as 1Password's agent. Remote file operations use SFTP and remote shell commands use an SSH exec channel. The SSH transport persists, but commands intentionally use fresh non-interactive Bash processes rather than a hidden stateful PTY.
|
||||
|
||||
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host.
|
||||
|
||||
@@ -34,9 +35,9 @@ From the installed bundle or this repository:
|
||||
The helper:
|
||||
|
||||
1. lets you select concrete aliases from `~/.ssh/config`;
|
||||
2. uses `ssh -G` to resolve HostName, User, Port, and IdentityFile;
|
||||
3. asks whether the selected host uses a private key or password;
|
||||
4. asks for a private-key passphrase when needed;
|
||||
2. uses `ssh -G` to resolve HostName, User, Port, IdentityFile, and IdentityAgent;
|
||||
3. asks whether the selected host uses an SSH agent, private key, or password;
|
||||
4. validates the selected agent socket or asks for a private-key passphrase when needed;
|
||||
5. obtains and displays the server's SHA256 host-key fingerprint;
|
||||
6. connects with `ssh2` to verify authentication;
|
||||
7. optionally assigns a display label and group;
|
||||
@@ -67,12 +68,13 @@ On POSIX systems the directory is mode `700` and both files are mode `600`. `hos
|
||||
|
||||
This is deliberately a **casual-disclosure boundary**, not protection against compromise of the local account: anyone who can read both files can decrypt the vault. Encryption prevents the host configuration and passwords from being exposed by accidentally viewing or copying `hosts.enc` alone.
|
||||
|
||||
The encrypted payload contains host endpoints, pinned host-key fingerprints, groups, and either:
|
||||
The encrypted payload contains host endpoints, pinned host-key fingerprints, groups, and one of:
|
||||
|
||||
- an SSH agent socket path;
|
||||
- a private-key path plus optional passphrase; or
|
||||
- the server password.
|
||||
|
||||
Private-key contents are not copied into the vault.
|
||||
Private-key contents are not copied into the vault. Agent-backed private keys remain inside the agent (including 1Password); pi-ssh stores only the socket path and asks `ssh2` to authenticate through it.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -88,20 +90,24 @@ The model first calls:
|
||||
ssh_connect({ hostId: "packaging-server" })
|
||||
```
|
||||
|
||||
It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. After the reviewed connection succeeds, the model uses the other `ssh_*` tools to complete the requested work. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. A new connection replaces the previous active connection, and session shutdown disconnects it.
|
||||
It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. The model calls sequential `ssh_connect` as a separate step and waits for the reviewed connection to succeed before using other `ssh_*` tools. `ssh_cd` explicitly changes the active remote workspace for subsequent shell, relative file, and relative search operations; it accepts absolute paths, paths relative to the current remote cwd, and `~/` paths relative to remote HOME. The model must also call `ssh_cd` as a separate step and wait for its successful result before issuing dependent remote operations. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. Unknown IDs report a bounded list of available imported IDs without exposing credentials. A new connection replaces the previous active connection, and session shutdown disconnects it.
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
- `ssh_connect` is the only runtime connection surface; it is agent-callable and permission-reviewed.
|
||||
- One persistent `ssh2` client is used for the active host.
|
||||
- Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd.
|
||||
- `ssh_connect` is the only runtime connection surface; it is agent-callable, permission-reviewed, sequential, and cancellable so connection replacement cannot overlap another tool call or remain stuck after user cancellation.
|
||||
- One persistent `ssh2` client is used for the active host; no interactive shell or PTY state is retained.
|
||||
- `ssh_cd` is a sequential state transition: it validates a remote directory and updates the active workspace without reconnecting. Dependent tool calls must wait for it to succeed.
|
||||
- HOME/cwd probes use bounded random-marker framing, require one absolute POSIX path, and preserve the previous state on malformed output, timeout, or cancellation.
|
||||
- Independent exec channels use bounded concurrency of four; shared SFTP operations remain serialized.
|
||||
- Each `ssh_bash` call opens a fresh exec channel and runs under `bash -lc` in the active remote cwd. A command-local `cd` is temporary, and `cd`, `export`, alias, function, or other shell state inside one call does not persist to the next call.
|
||||
- SFTP provides remote reads and writes.
|
||||
- Writes use a temporary remote file and prefer OpenSSH's atomic rename SFTP extension when the server supports it.
|
||||
- A pinned SHA256 host-key mismatch fails closed.
|
||||
- Connection loss fails closed; the extension does not silently replay a command.
|
||||
- Remote `AGENTS.md` and `CLAUDE.md` files are never discovered or injected.
|
||||
- `ssh_find` and `ssh_grep` perform capability detection inside each approved call and return at most 200 bounded result lines.
|
||||
- `ssh_find` and `ssh_grep` perform capability detection inside each approved call and return at most 200 bounded result lines. Search targets are always separate from the execution cwd, so `ssh_grep` can target a single remote file without attempting to enter it as a directory. A 30-second timeout reports the resolved root and instructs the model to narrow it with `ssh_find`.
|
||||
- RTK treats only `ssh_bash` as a Bash output-compaction alias; it does not rewrite remote commands or process remote search/read results.
|
||||
- Permission previews display resolved absolute remote paths together with the original relative or `~/` request; remote paths never enter local filesystem gates.
|
||||
|
||||
### Adaptive remote search
|
||||
|
||||
@@ -111,13 +117,15 @@ It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`.
|
||||
fd → fdfind → git ls-files → find
|
||||
```
|
||||
|
||||
`ssh_grep` defaults to literal, case-insensitive content matching with this backend order:
|
||||
`ssh_grep` defaults to literal, case-insensitive content matching with this backend order. Hidden paths are excluded unless `includeHidden` is true, and `include` is a basename-only glob such as `*.ts` (globs containing `/` are rejected):
|
||||
|
||||
```text
|
||||
ripgrep → git grep → find + grep
|
||||
```
|
||||
|
||||
No backend is installed or uploaded. The tools use what the server already provides, report the chosen backend and truncation state, and cap `limit` at 200. A genuine no-match result succeeds with zero rows; invalid regexes, missing roots, and backend failures remain errors even though output passes through bounded `head`/`cut` stages. Narrow `path` and `pattern` when truncated rather than increasing the limit. Direct `find`, `fd`, `grep`, and `rg` commands remain forbidden through `ssh_bash`; use the structured search tools instead. Regex mode (`literal: false`) follows the selected backend's regex dialect.
|
||||
No backend is installed or uploaded. The tools use what the server already provides, report the chosen backend and truncation state, and cap `limit` at 200. `literal: false` accepts the portable POSIX ERE subset shared by the backends; Git and fallback grep are explicitly run in ERE mode. Grep results use bounded NUL-delimited path/line/content records, so newline-containing filenames cannot corrupt match counts or truncation. Backend stderr is kept out of result rows and is reported as a warning on success or failure detail on error.
|
||||
|
||||
A genuine no-match result—including the `find + grep` fallback—succeeds with zero rows; invalid regexes, missing roots, and backend failures remain errors. Search paths accept absolute paths, relative paths, `~`, and `~/...`; `~user` expansion is rejected. Narrow `path` and `pattern` when truncated rather than increasing the limit. Direct `find`, `fd`, `grep`, and `rg` commands remain forbidden through `ssh_bash`; use the structured search tools instead.
|
||||
|
||||
The remote host must provide `bash`. SFTP support is required for file tools.
|
||||
|
||||
@@ -126,7 +134,7 @@ The remote host must provide `bash`. SFTP support is required for file tools.
|
||||
All remote operations enter the bundle's existing permission chain:
|
||||
|
||||
- `ssh_connect` starts as `ask`, so AutoReview can verify that the direct user request names the requested imported host;
|
||||
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
|
||||
- `ssh_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
|
||||
- `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`;
|
||||
- deterministic hard denies remain denies;
|
||||
- asks enter the configured `auto-review` authorizer;
|
||||
@@ -153,6 +161,8 @@ Important files:
|
||||
|
||||
- `index.ts` — Pi extension and tool registration
|
||||
- `src/ssh2-transport.ts` — persistent ssh2, exec, and SFTP transport
|
||||
- `src/remote-bash.ts` — stateless Bash adapter pinned to the active remote cwd
|
||||
- `src/remote-cwd.ts` — explicit remote workspace path resolution
|
||||
- `src/config.ts` — validated configuration types
|
||||
- `src/vault.ts` — AES-GCM vault
|
||||
- `src/import.ts` — `ssh -G` import helpers
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server:
|
||||
|
||||
- `ssh_connect`
|
||||
- `ssh_cd`
|
||||
- `ssh_read`
|
||||
- `ssh_write`
|
||||
- `ssh_edit`
|
||||
@@ -33,7 +34,7 @@ The resulting host ID is the only runtime selector. When a direct user request n
|
||||
ssh_connect({ hostId: "<host-id>", remotePath?: "/absolute/or/~/path" })
|
||||
```
|
||||
|
||||
`ssh_connect` is the only runtime connection surface. It is a model tool governed by `pi-permission-system`; there is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
|
||||
`ssh_connect` is the only runtime connection surface. It is a sequential, cancellable model tool governed by `pi-permission-system`: the agent calls it as a separate step and waits for success before dependent remote tools, so establishing or replacing a connection cannot overlap another tool call. Unknown IDs return at most ten sorted imported IDs and never endpoint credentials. There is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
|
||||
|
||||
Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored.
|
||||
|
||||
@@ -69,11 +70,13 @@ Import displays the observed host-key algorithm and SHA256 fingerprint for expli
|
||||
|
||||
## Transport
|
||||
|
||||
One persistent `ssh2.Client` belongs to the active host. Connection loss fails closed and no operation is automatically replayed.
|
||||
One persistent `ssh2.Client` belongs to the active host. The transport persists, but there is no persistent interactive shell or PTY. Connection loss fails closed and no operation is automatically replayed.
|
||||
|
||||
### Shell
|
||||
|
||||
`ssh_bash` opens exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
|
||||
`ssh_bash` opens a fresh exec channel and non-interactive Bash process for every call. Commands run through `bash -lc` after changing to the active remote cwd. The cwd supplied by Pi's local Bash factory is ignored at the transport adapter boundary; only connection state may select the remote cwd. A command-local `cd` is intentionally temporary; shell-local `cd`, exports, aliases, functions, and other process state do not persist across calls. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
|
||||
|
||||
`ssh_cd` is the explicit persistent workspace operation and declares Pi's sequential execution mode so it cannot overlap sibling tool calls. It resolves absolute paths, paths relative to the active remote cwd, and `~/` paths relative to remote HOME; validates the directory by executing `pwd` from it; and updates connection state without reconnecting. The model calls it as a separate step and waits for success before issuing subsequent relative file/search operations or `ssh_bash` calls that depend on the updated cwd.
|
||||
|
||||
### Files
|
||||
|
||||
@@ -92,10 +95,13 @@ Remote paths map from Pi's local factory cwd into the selected remote cwd, but p
|
||||
Search tools execute one capability-adaptive, bounded shell pipeline inside the already approved tool call. They never install or upload binaries.
|
||||
|
||||
- `ssh_find`: `fd` → `fdfind` → `git ls-files` → `find`; fixed filename/path substring semantics.
|
||||
- `ssh_grep`: `rg` → `git grep` → `find -exec grep`; literal case-insensitive semantics by default.
|
||||
- each backend emits at most `limit + 1` lines so truncation is explicit; public `limit` is 1–200; each returned line is capped.
|
||||
- relative paths resolve against remote cwd and `~/` against remote home.
|
||||
- `ssh_grep`: `rg` → `git grep` → `find + grep`; literal case-insensitive semantics by default, optional hidden-path inclusion, basename-only include globs, and a portable POSIX ERE subset when literal mode is disabled.
|
||||
- each backend emits at most `limit + 1` NUL-delimited path/line/content records so truncation is explicit and newline filenames remain one result; public `limit` is 1–200; each returned line is capped.
|
||||
- relative paths resolve against remote cwd and `~/` against remote home; other `~user` forms are rejected.
|
||||
- the search target root is passed to the backend as an absolute argument, while the search process executes from the active remote cwd; a single-file `ssh_grep` target is never used as a process cwd.
|
||||
- searches time out after 30 seconds with the resolved root and explicit guidance to narrow it before retrying.
|
||||
- user strings are single-quoted as shell arguments and NUL/newline input is rejected.
|
||||
- backend stdout contains only parseable results; stderr is captured independently for warnings and failure diagnostics.
|
||||
- capability detection occurs only within the reviewed search call.
|
||||
- search output is normalized by `pi-ssh` and excluded from RTK compaction.
|
||||
|
||||
@@ -106,7 +112,7 @@ Direct search commands remain denied through `ssh_bash`; structured search tools
|
||||
There is one permission gate: `pi-permission-system`.
|
||||
|
||||
- `ssh_connect` defaults to `ask`; its preview resolves the imported host ID to the non-secret endpoint, port, and requested/default cwd before connection.
|
||||
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`.
|
||||
- `ssh_cd`, `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`; remote path extractors disable local path normalization for all six tools, while previews resolve relative and `~/` requests against the active remote cwd/HOME and display both requested and absolute paths.
|
||||
- `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`.
|
||||
- Bash hard denies remain denies.
|
||||
- All asks enter the configured authorizer chain.
|
||||
@@ -114,8 +120,10 @@ There is one permission gate: `pi-permission-system`.
|
||||
|
||||
Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials.
|
||||
|
||||
Connection HOME/cwd discovery and `ssh_cd` validation use a bounded random-marker probe over a cancellable exec channel. The parser accepts exactly one framed absolute POSIX path; startup banners, malformed/multiline values, overflow, timeout, or cancellation cannot update connection state. Independent exec operations use at most four concurrent SSH channels, while SFTP operations remain serialized.
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
Connections are created only by an approved `ssh_connect` call after session start. They are not persisted or automatically resumed. Connecting another imported host disposes the previous client, and session shutdown disposes the active client.
|
||||
|
||||
The system prompt states that default tools are local and `ssh_*` tools are remote after a connection becomes active. It does not include remote file content.
|
||||
The system prompt states that default tools are local, `ssh_*` tools are remote, and each `ssh_bash` call starts a fresh non-interactive shell after a connection becomes active. It directs persistent workspace changes through `ssh_cd` and does not include remote file content.
|
||||
|
||||
+68
-48
@@ -6,7 +6,6 @@ import {
|
||||
createEditTool,
|
||||
createReadTool,
|
||||
createWriteTool,
|
||||
type BashOperations,
|
||||
type EditOperations,
|
||||
type ReadOperations,
|
||||
type WriteOperations,
|
||||
@@ -17,19 +16,27 @@ import {
|
||||
type SshPermissionConnection,
|
||||
} from "./permission-integration.ts";
|
||||
import {
|
||||
getConfiguredHost,
|
||||
parseConnectInput,
|
||||
SSH_CONNECT_TOOL_METADATA,
|
||||
type HostSelection,
|
||||
} from "./src/agent-connection.ts";
|
||||
import { loadVault } from "./src/vault.ts";
|
||||
import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts";
|
||||
import type { PiSshConfig, SshHostConfig } from "./src/config.ts";
|
||||
import type { SshHostConfig } from "./src/config.ts";
|
||||
import {
|
||||
runRemoteFind,
|
||||
runRemoteGrep,
|
||||
type RemoteFindInput,
|
||||
type RemoteGrepInput,
|
||||
} from "./src/remote-search.ts";
|
||||
import { createRemoteBashOps } from "./src/remote-bash.ts";
|
||||
import {
|
||||
changeRemoteCwd,
|
||||
mapLocalPathToRemote,
|
||||
SSH_CD_EXECUTION_MODE,
|
||||
} from "./src/remote-cwd.ts";
|
||||
import { probeRemotePath } from "./src/remote-probe.ts";
|
||||
|
||||
|
||||
interface SshConnection {
|
||||
@@ -43,17 +50,6 @@ interface SshConnection {
|
||||
}
|
||||
|
||||
|
||||
function mapLocalPathToRemote(path: string, connection: SshConnection): string {
|
||||
if (path === connection.localCwd) return connection.remoteCwd;
|
||||
if (path.startsWith(`${connection.localCwd}/`)) {
|
||||
return `${connection.remoteCwd}${path.slice(connection.localCwd.length)}`;
|
||||
}
|
||||
if (path === connection.localHome) return connection.remoteHome;
|
||||
if (path.startsWith(`${connection.localHome}/`)) {
|
||||
return `${connection.remoteHome}${path.slice(connection.localHome.length)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function createRemoteReadOps(connection: SshConnection, transport: RemoteTransport): ReadOperations {
|
||||
return {
|
||||
@@ -87,17 +83,7 @@ function createRemoteEditOps(connection: SshConnection, transport: RemoteTranspo
|
||||
};
|
||||
}
|
||||
|
||||
function createRemoteBashOps(transport: RemoteTransport): BashOperations {
|
||||
return {
|
||||
exec: (command, cwd, { onData, signal, timeout }) => transport.exec(command, cwd, { onData, signal, timeout }),
|
||||
};
|
||||
}
|
||||
|
||||
function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostConfig {
|
||||
const host = config.hosts[hostId];
|
||||
if (!host) throw new Error(`unknown pi-ssh host '${hostId}'; run ssh_config.sh import ${hostId}`);
|
||||
return host;
|
||||
}
|
||||
|
||||
function resolveRequestedPath(selection: HostSelection, host: SshHostConfig, remoteHome: string, remotePwd: string): string {
|
||||
const requested = selection.remotePath ?? host.defaultCwd ?? remotePwd;
|
||||
@@ -106,30 +92,22 @@ function resolveRequestedPath(selection: HostSelection, host: SshHostConfig, rem
|
||||
return requested;
|
||||
}
|
||||
|
||||
async function captureChecked(transport: RemoteTransport, command: string, cwd = "."): Promise<string> {
|
||||
const result = await transport.capture(command, cwd, 20);
|
||||
if (result.exitCode !== 0) {
|
||||
const message = result.output.toString("utf8").trim();
|
||||
throw new Error(message || `remote command failed with exit code ${result.exitCode}`);
|
||||
}
|
||||
return result.output.toString("utf8").trim();
|
||||
}
|
||||
|
||||
async function connectSelection(
|
||||
selection: HostSelection,
|
||||
localCwd: string,
|
||||
localHome: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ connection: SshConnection; transport: Ssh2Transport }> {
|
||||
const config = loadVault();
|
||||
const host = getConfiguredHost(config, selection.hostId);
|
||||
const transport = new Ssh2Transport(host);
|
||||
try {
|
||||
await transport.connect();
|
||||
const remoteHome = await captureChecked(transport, 'printf "%s" "$HOME"');
|
||||
const remotePwd = await captureChecked(transport, "pwd");
|
||||
if (!remoteHome || !remotePwd) throw new Error("remote HOME/cwd probe returned empty output");
|
||||
await transport.connect(signal);
|
||||
const remoteHome = await probeRemotePath(transport, "home", ".", signal);
|
||||
const remotePwd = await probeRemotePath(transport, "cwd", ".", signal);
|
||||
const requestedPath = resolveRequestedPath(selection, host, remoteHome, remotePwd);
|
||||
const remoteCwd = await captureChecked(transport, "pwd", requestedPath);
|
||||
const remoteCwd = await probeRemotePath(transport, "cwd", requestedPath, signal);
|
||||
return {
|
||||
connection: {
|
||||
hostId: selection.hostId,
|
||||
@@ -169,7 +147,7 @@ export default function piSshExtension(pi: ExtensionAPI): void {
|
||||
const localRead = createReadTool(localCwd);
|
||||
const localWrite = createWriteTool(localCwd);
|
||||
const localEdit = createEditTool(localCwd);
|
||||
const localBash = createBashTool(localCwd);
|
||||
const localBash = createBashTool(localCwd, { exposeSessionEnvironment: false });
|
||||
|
||||
let connection: SshConnection | null = null;
|
||||
let transport: Ssh2Transport | null = null;
|
||||
@@ -199,9 +177,13 @@ export default function piSshExtension(pi: ExtensionAPI): void {
|
||||
|
||||
pi.registerTool({
|
||||
...SSH_CONNECT_TOOL_METADATA,
|
||||
async execute(_id, params) {
|
||||
async execute(_id, params, signal) {
|
||||
const selection = parseConnectInput(params as Record<string, unknown>);
|
||||
const connected = await connectSelection(selection, localCwd, localHome);
|
||||
const connected = await connectSelection(selection, localCwd, localHome, signal);
|
||||
if (signal?.aborted) {
|
||||
await connected.transport.dispose();
|
||||
throw new Error("SSH connection aborted");
|
||||
}
|
||||
await activateConnection(connected.connection, connected.transport);
|
||||
const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`;
|
||||
return {
|
||||
@@ -216,6 +198,37 @@ export default function piSshExtension(pi: ExtensionAPI): void {
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "ssh_cd",
|
||||
label: "ssh_cd",
|
||||
description: "Change the active SSH workspace directory as a reviewed, persistent state transition. Call ssh_cd separately and wait for it to succeed before issuing ssh_bash or relative remote file/search operations that depend on the new directory.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
description: "Remote directory: absolute, relative to the active remote cwd, or ~/ relative to remote HOME",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
executionMode: SSH_CD_EXECUTION_MODE,
|
||||
async execute(_id, params, signal) {
|
||||
const active = requireSsh("ssh_cd");
|
||||
const changed = await changeRemoteCwd(
|
||||
active.connection,
|
||||
(params as { path: string }).path,
|
||||
(requestedCwd) => probeRemotePath(active.transport, "cwd", requestedCwd, signal),
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: `Remote cwd changed from ${changed.previousCwd} to ${changed.remoteCwd}. Dependent remote tools may now run.` }],
|
||||
details: changed,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...localRead,
|
||||
name: "ssh_read",
|
||||
@@ -287,15 +300,16 @@ export default function piSshExtension(pi: ExtensionAPI): void {
|
||||
pi.registerTool({
|
||||
name: "ssh_grep",
|
||||
label: "ssh_grep",
|
||||
description: "Search remote file contents using ripgrep, git grep, or grep. Literal case-insensitive matching is the default; results are bounded and require an active SSH2 connection.",
|
||||
description: "Search remote file contents using ripgrep, git grep, or grep with consistent hidden-file, basename-glob, and portable POSIX ERE semantics. Literal case-insensitive matching is the default; results are bounded and require an active SSH2 connection.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string", description: "Text or regular expression to search for" },
|
||||
pattern: { type: "string", description: "Text to search for, or a portable POSIX ERE when literal is false" },
|
||||
path: { type: "string", description: "Remote root path; defaults to the active remote cwd" },
|
||||
literal: { type: "boolean", description: "Treat pattern as fixed text (default: true)" },
|
||||
literal: { type: "boolean", description: "Treat pattern as fixed text; false uses portable POSIX ERE syntax (default: true)" },
|
||||
caseSensitive: { type: "boolean", description: "Use case-sensitive matching (default: false)" },
|
||||
include: { type: "string", description: "Optional file glob such as *.ts" },
|
||||
include: { type: "string", description: "Optional basename-only glob such as *.ts; / is not allowed" },
|
||||
includeHidden: { type: "boolean", description: "Search hidden files and directories (default: false)" },
|
||||
limit: { type: "integer", minimum: 1, maximum: 200, description: "Maximum result lines (default: 50)" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
@@ -321,11 +335,15 @@ export default function piSshExtension(pi: ExtensionAPI): void {
|
||||
...localBash,
|
||||
name: "ssh_bash",
|
||||
label: "ssh_bash",
|
||||
description: `Run a shell command through the active SSH2 connection. ${localBash.description}`,
|
||||
description: "Run a non-search shell command on the active SSH server. Every call starts a fresh non-interactive Bash process in the active remote cwd; cd, exported variables, aliases, and other shell state do not persist. For a persistent workspace change, call ssh_cd separately and wait for success before calling ssh_bash. Use command-local cd only for an intentionally temporary directory change. Relative paths are remote. Use ssh_find or ssh_grep instead of find, fd, grep, or rg.",
|
||||
promptSnippet: undefined,
|
||||
promptGuidelines: undefined,
|
||||
async execute(id, params, signal, onUpdate) {
|
||||
const active = requireSsh("ssh_bash");
|
||||
return createBashTool(localCwd, { operations: createRemoteBashOps(active.transport) })
|
||||
.execute(id, params, signal, onUpdate);
|
||||
return createBashTool(active.connection.remoteCwd, {
|
||||
operations: createRemoteBashOps(active.connection, active.transport),
|
||||
exposeSessionEnvironment: false,
|
||||
}).execute(id, params, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -341,9 +359,11 @@ export default function piSshExtension(pi: ExtensionAPI): void {
|
||||
`\n\n# Remote SSH connection\n\n` +
|
||||
`An SSH2 connection to configured host ${connection.remote} on port ${connection.port} is active. ` +
|
||||
`The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` +
|
||||
`Use ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
|
||||
`Use ssh_cd, ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
|
||||
`Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` +
|
||||
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory.`;
|
||||
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory. ` +
|
||||
`For a persistent workspace change, call ssh_cd as a separate step and wait for its successful result before issuing dependent remote tool calls. ` +
|
||||
`Each ssh_bash call starts a fresh non-interactive shell, so use command-local cd only for an intentionally temporary change; cd, exports, aliases, and other shell state inside one command do not persist to the next call.`;
|
||||
return { systemPrompt: `${event.systemPrompt}${guidance}` };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import type { PermissionsService } from "@gotgenes/pi-permission-system";
|
||||
import { resolveRemoteCwd } from "./src/remote-cwd.ts";
|
||||
|
||||
export interface SshPermissionConnection {
|
||||
remote: string;
|
||||
port?: number;
|
||||
remoteCwd: string;
|
||||
remoteHome?: string;
|
||||
}
|
||||
|
||||
export interface SshPermissionIntegrationDependencies {
|
||||
@@ -17,8 +19,8 @@ export interface SshPermissionIntegrationDependencies {
|
||||
type PermissionIntegrationApi = Pick<ExtensionAPI, "events" | "on">;
|
||||
type ToolInput = Record<string, unknown>;
|
||||
|
||||
const REMOTE_FILE_TOOLS = ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
|
||||
const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_FILE_TOOLS, "ssh_bash"] as const;
|
||||
const REMOTE_PATH_TOOLS = ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
|
||||
const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_PATH_TOOLS, "ssh_bash"] as const;
|
||||
|
||||
function inline(value: string, limit = 240): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
@@ -40,6 +42,23 @@ function formatTarget(connection: SshPermissionConnection | null): string {
|
||||
return `SSH target '${inline(connection.remote)}${port}' in remote cwd '${inline(connection.remoteCwd)}'`;
|
||||
}
|
||||
|
||||
function remotePathDetail(
|
||||
requested: string,
|
||||
connection: SshPermissionConnection | null,
|
||||
label = "remote path",
|
||||
): string {
|
||||
let resolved: string | undefined;
|
||||
if (connection?.remoteHome) {
|
||||
try {
|
||||
resolved = resolveRemoteCwd(requested, connection.remoteCwd, connection.remoteHome);
|
||||
} catch {
|
||||
// Execution performs authoritative validation; preserve the raw request.
|
||||
}
|
||||
}
|
||||
if (!resolved || resolved === requested) return `${label} '${inline(requested)}'`;
|
||||
return `${label} '${inline(resolved)}' (requested '${inline(requested)}')`;
|
||||
}
|
||||
|
||||
export function formatSshPermissionInput(
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
@@ -55,8 +74,12 @@ export function formatSshPermissionInput(
|
||||
return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`;
|
||||
}
|
||||
|
||||
if (toolName === "ssh_cd") {
|
||||
return `${target}; change the active ${path ? remotePathDetail(path, connection, "remote cwd to") : "remote cwd to '<unspecified>'"}`;
|
||||
}
|
||||
|
||||
if (toolName === "ssh_read") {
|
||||
const details = path ? [`remote path '${inline(path)}'`] : ["an unspecified remote path"];
|
||||
const details = path ? [remotePathDetail(path, connection)] : ["an unspecified remote path"];
|
||||
if (typeof input.offset === "number") details.push(`offset ${input.offset}`);
|
||||
if (typeof input.limit === "number") details.push(`limit ${input.limit}`);
|
||||
return `${target}; read ${details.join(", ")}`;
|
||||
@@ -64,20 +87,20 @@ export function formatSshPermissionInput(
|
||||
|
||||
if (toolName === "ssh_write") {
|
||||
const content = stringField(input, "content") ?? "";
|
||||
return `${target}; write remote path '${inline(path ?? "<unspecified>")}' (${countLines(content)} lines, ${content.length} characters)`;
|
||||
return `${target}; write ${path ? remotePathDetail(path, connection) : "remote path '<unspecified>'"} (${countLines(content)} lines, ${content.length} characters)`;
|
||||
}
|
||||
|
||||
if (toolName === "ssh_edit") {
|
||||
const oldText = stringField(input, "oldText") ?? "";
|
||||
const newText = stringField(input, "newText") ?? "";
|
||||
return `${target}; edit remote path '${inline(path ?? "<unspecified>")}' (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`;
|
||||
return `${target}; edit ${path ? remotePathDetail(path, connection) : "remote path '<unspecified>'"} (replace ${countLines(oldText)} lines with ${countLines(newText)} lines)`;
|
||||
}
|
||||
|
||||
if (toolName === "ssh_find" || toolName === "ssh_grep") {
|
||||
const pattern = inline(stringField(input, "pattern") ?? "<unspecified>");
|
||||
const operation = toolName === "ssh_find" ? "find remote files" : "search remote file contents";
|
||||
const details = [
|
||||
`under '${inline(path ?? ".")}'`,
|
||||
remotePathDetail(path ?? ".", connection, "under"),
|
||||
`for '${pattern}'`,
|
||||
`limit ${typeof input.limit === "number" ? input.limit : 50}`,
|
||||
];
|
||||
@@ -140,7 +163,7 @@ export function installSshPermissionIntegration(
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const toolName of REMOTE_FILE_TOOLS) {
|
||||
for (const toolName of REMOTE_PATH_TOOLS) {
|
||||
pending.push(service.registerToolAccessExtractor(toolName, () => undefined));
|
||||
}
|
||||
disposers = pending;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import readline from "node:readline/promises";
|
||||
import {
|
||||
effectiveIdentityAgent,
|
||||
effectiveValue,
|
||||
effectiveValues,
|
||||
listDirectSshAliases,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from "../src/import.ts";
|
||||
import { expandUserPath } from "../src/config.ts";
|
||||
import { probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
|
||||
import { probeRemotePath } from "../src/remote-probe.ts";
|
||||
import { loadVaultOrEmpty, rotateVaultKey, saveVault } from "../src/vault.ts";
|
||||
|
||||
async function question(prompt) {
|
||||
@@ -105,11 +107,19 @@ async function importHost(config, alias) {
|
||||
const identityFiles = effectiveValues(effective, "identityfile")
|
||||
.map((path) => path.replace(/^"|"$/g, ""))
|
||||
.filter((path) => existsSync(expandUserPath(path)));
|
||||
const defaultMode = identityFiles.length > 0 ? "key" : "password";
|
||||
const modeInput = (await question(`Authentication [key/password] (${defaultMode}): `)).toLowerCase();
|
||||
const agentSocket = effectiveIdentityAgent(effective);
|
||||
const agentAvailable = agentSocket && (agentSocket === "pageant" || existsSync(expandUserPath(agentSocket)));
|
||||
const defaultMode = agentAvailable ? "agent" : identityFiles.length > 0 ? "key" : "password";
|
||||
const modeInput = (await question(`Authentication [agent/key/password] (${defaultMode}): `)).toLowerCase();
|
||||
const mode = modeInput || defaultMode;
|
||||
let auth;
|
||||
if (mode === "key") {
|
||||
if (mode === "agent") {
|
||||
const socketPath = await question(`SSH agent socket${agentSocket ? ` (${agentSocket})` : ""}: `) || agentSocket;
|
||||
if (!socketPath || (socketPath !== "pageant" && !existsSync(expandUserPath(socketPath)))) {
|
||||
throw new Error(`SSH agent socket does not exist: ${socketPath ?? ""}`);
|
||||
}
|
||||
auth = { type: "agent", socketPath };
|
||||
} else if (mode === "key") {
|
||||
const suggested = identityFiles[0] ?? "";
|
||||
const identityFile = await question(`Private key path${suggested ? ` (${suggested})` : ""}: `) || suggested;
|
||||
if (!identityFile || !existsSync(expandUserPath(identityFile))) throw new Error(`private key does not exist: ${identityFile}`);
|
||||
@@ -122,7 +132,7 @@ async function importHost(config, alias) {
|
||||
if (!password) throw new Error("server password cannot be empty");
|
||||
auth = { type: "password", password, method: "auto" };
|
||||
} else {
|
||||
throw new Error("authentication must be key or password");
|
||||
throw new Error("authentication must be agent, key, or password");
|
||||
}
|
||||
|
||||
console.log("Obtaining SSH host key fingerprint...");
|
||||
@@ -136,10 +146,8 @@ async function importHost(config, alias) {
|
||||
try {
|
||||
console.log("Testing SSH2 authentication...");
|
||||
await transport.connect();
|
||||
const result = await transport.capture('printf "%s\\n%s" "$HOME" "$(pwd)"', ".", 20);
|
||||
if (result.exitCode !== 0) throw new Error("remote HOME/cwd probe failed");
|
||||
const [remoteHome, remoteCwd] = result.output.toString("utf8").trim().split(/\r?\n/, 2);
|
||||
if (!remoteHome || !remoteCwd) throw new Error("remote HOME/cwd probe returned incomplete output");
|
||||
await probeRemotePath(transport, "home");
|
||||
const remoteCwd = await probeRemotePath(transport, "cwd");
|
||||
host.defaultCwd = remoteCwd;
|
||||
console.log(`Connected successfully; default cwd: ${remoteCwd}`);
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PiSshConfig, SshHostConfig } from "./config.ts";
|
||||
|
||||
export interface HostSelection {
|
||||
hostId: string;
|
||||
remotePath?: string;
|
||||
@@ -6,7 +8,7 @@ export interface HostSelection {
|
||||
export const SSH_CONNECT_TOOL_METADATA = {
|
||||
name: "ssh_connect",
|
||||
label: "ssh_connect",
|
||||
description: "Establish a persistent SSH2 connection to an explicitly imported host. Use this when the user names a remote server as part of a concrete task; the connection request is reviewed before any network connection is opened.",
|
||||
description: "Establish a persistent SSH2 connection to an explicitly imported host as a sequential state transition. Use this as a separate step when the user names a remote server as part of a concrete task, and wait for success before calling dependent ssh_* tools; the connection request is reviewed before any network connection is opened.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -16,6 +18,7 @@ export const SSH_CONNECT_TOOL_METADATA = {
|
||||
required: ["hostId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
executionMode: "sequential",
|
||||
} as const;
|
||||
|
||||
export function parseConnectInput(input: Record<string, unknown>): HostSelection {
|
||||
@@ -31,3 +34,16 @@ export function parseConnectInput(input: Record<string, unknown>): HostSelection
|
||||
}
|
||||
return { hostId, remotePath };
|
||||
}
|
||||
|
||||
export function getConfiguredHost(config: PiSshConfig, hostId: string): SshHostConfig {
|
||||
const host = config.hosts[hostId];
|
||||
if (host) return host;
|
||||
const hostIds = Object.keys(config.hosts).sort();
|
||||
if (hostIds.length === 0) {
|
||||
throw new Error(`unknown pi-ssh host '${hostId}'; no hosts are imported; run ssh_config.sh import <alias>`);
|
||||
}
|
||||
const shown = hostIds.slice(0, 10);
|
||||
const remaining = hostIds.length - shown.length;
|
||||
const available = `${shown.join(", ")}${remaining > 0 ? `, … (+${remaining} more)` : ""}`;
|
||||
throw new Error(`unknown pi-ssh host '${hostId}'; available imported host IDs: ${available}`);
|
||||
}
|
||||
|
||||
+13
-2
@@ -13,7 +13,12 @@ export interface PrivateKeyAuthConfig {
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig;
|
||||
export interface AgentAuthConfig {
|
||||
type: "agent";
|
||||
socketPath: string;
|
||||
}
|
||||
|
||||
export type SshAuthConfig = PasswordAuthConfig | PrivateKeyAuthConfig | AgentAuthConfig;
|
||||
|
||||
export interface SshHostConfig {
|
||||
label?: string;
|
||||
@@ -108,7 +113,13 @@ function validateAuth(value: unknown, name: string): SshAuthConfig {
|
||||
...(auth.passphrase === undefined ? {} : { passphrase: nonEmptyString(auth.passphrase, `${name}.passphrase`) }),
|
||||
};
|
||||
}
|
||||
throw new Error(`${name}.type must be password or private-key`);
|
||||
if (auth.type === "agent") {
|
||||
return {
|
||||
type: "agent",
|
||||
socketPath: nonEmptyString(auth.socketPath, `${name}.socketPath`),
|
||||
};
|
||||
}
|
||||
throw new Error(`${name}.type must be password, private-key, or agent`);
|
||||
}
|
||||
|
||||
function validateHost(value: unknown, name: string): SshHostConfig {
|
||||
|
||||
@@ -37,6 +37,18 @@ export function effectiveValues(config: EffectiveSshConfig, key: string): string
|
||||
return config.get(key.toLowerCase()) ?? [];
|
||||
}
|
||||
|
||||
export function effectiveIdentityAgent(
|
||||
config: EffectiveSshConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const configured = effectiveValue(config, "identityagent")?.replace(/^"|"$/g, "");
|
||||
if (!configured || configured.toLowerCase() === "none") return undefined;
|
||||
if (configured === "SSH_AUTH_SOCK" || configured === "$SSH_AUTH_SOCK" || configured === "${SSH_AUTH_SOCK}") {
|
||||
return env.SSH_AUTH_SOCK;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
export function listDirectSshAliases(configPath = join(homedir(), ".ssh", "config")): string[] {
|
||||
if (!existsSync(configPath)) return [];
|
||||
const aliases: string[] = [];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { BashOperations } from "@earendil-works/pi-coding-agent";
|
||||
import type { RemoteTransport } from "./ssh2-transport.ts";
|
||||
|
||||
export interface RemoteBashConnection {
|
||||
remoteCwd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt Pi's Bash output machinery to the active remote workspace.
|
||||
*
|
||||
* The cwd supplied by Pi's Bash factory is deliberately ignored: it belongs
|
||||
* to the factory's filesystem namespace and must never leak into SSH command
|
||||
* execution. The connection's mutable remoteCwd is the sole shell base.
|
||||
*/
|
||||
export function createRemoteBashOps(
|
||||
connection: RemoteBashConnection,
|
||||
transport: RemoteTransport,
|
||||
): BashOperations {
|
||||
return {
|
||||
exec: (command, _factoryCwd, { onData, signal, timeout }) => {
|
||||
if (!connection.remoteCwd.startsWith("/")) {
|
||||
throw new Error(`ssh_bash requires an absolute remote cwd, received '${connection.remoteCwd}'`);
|
||||
}
|
||||
return transport.exec(command, connection.remoteCwd, { onData, signal, timeout });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { posix as posixPath } from "node:path";
|
||||
|
||||
export const SSH_CD_EXECUTION_MODE = "sequential" as const;
|
||||
|
||||
export interface RemoteWorkspaceConnection {
|
||||
remoteCwd: string;
|
||||
remoteHome: string;
|
||||
}
|
||||
|
||||
export interface RemotePathMappingConnection extends RemoteWorkspaceConnection {
|
||||
localCwd: string;
|
||||
localHome: string;
|
||||
}
|
||||
|
||||
export interface RemoteCwdChange {
|
||||
previousCwd: string;
|
||||
remoteCwd: string;
|
||||
}
|
||||
|
||||
export function resolveRemoteCwd(path: string, currentCwd: string, remoteHome: string): string {
|
||||
if (typeof path !== "string" || path.length === 0) {
|
||||
throw new Error("path must be a non-empty string");
|
||||
}
|
||||
if (/[\0\r\n]/u.test(path)) {
|
||||
throw new Error("path must not contain NUL or newline characters");
|
||||
}
|
||||
if (!currentCwd.startsWith("/") || !remoteHome.startsWith("/")) {
|
||||
throw new Error("the active SSH connection has an invalid remote workspace");
|
||||
}
|
||||
if (path === "~") return posixPath.normalize(remoteHome);
|
||||
if (path.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, path.slice(2)));
|
||||
if (path.startsWith("~")) throw new Error("path supports only '~' or '~/' home expansion");
|
||||
if (path.startsWith("/")) return posixPath.normalize(path);
|
||||
return posixPath.normalize(posixPath.join(currentCwd, path));
|
||||
}
|
||||
|
||||
export async function changeRemoteCwd(
|
||||
connection: RemoteWorkspaceConnection,
|
||||
path: string,
|
||||
verifyDirectory: (requestedCwd: string) => Promise<string>,
|
||||
): Promise<RemoteCwdChange> {
|
||||
const previousCwd = connection.remoteCwd;
|
||||
const requestedCwd = resolveRemoteCwd(path, previousCwd, connection.remoteHome);
|
||||
const remoteCwd = await verifyDirectory(requestedCwd);
|
||||
connection.remoteCwd = remoteCwd;
|
||||
return { previousCwd, remoteCwd };
|
||||
}
|
||||
|
||||
export function mapLocalPathToRemote(path: string, connection: RemotePathMappingConnection): string {
|
||||
if (path === connection.localCwd) return connection.remoteCwd;
|
||||
if (path.startsWith(`${connection.localCwd}/`)) {
|
||||
return `${connection.remoteCwd}${path.slice(connection.localCwd.length)}`;
|
||||
}
|
||||
if (path === connection.localHome) return connection.remoteHome;
|
||||
if (path.startsWith(`${connection.localHome}/`)) {
|
||||
return `${connection.remoteHome}${path.slice(connection.localHome.length)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { posix as posixPath } from "node:path";
|
||||
import type { RemoteTransport } from "./ssh2-transport.ts";
|
||||
|
||||
const PROBE_TIMEOUT_SECONDS = 20;
|
||||
const MAX_PROBE_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
export type RemotePathProbe = "home" | "cwd";
|
||||
|
||||
function probeCommand(kind: RemotePathProbe, token: string): { command: string; start: string; end: string } {
|
||||
const start = `__PI_SSH_PROBE_${token}_START__`;
|
||||
const end = `__PI_SSH_PROBE_${token}_END__`;
|
||||
const assign = kind === "home"
|
||||
? "pi_ssh_probe_value=$HOME"
|
||||
: "pi_ssh_probe_value=$(pwd -P) || exit $?";
|
||||
return {
|
||||
command: `${assign}\nprintf '%s%s%s' '${start}' "$pi_ssh_probe_value" '${end}'`,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRemotePathProbe(output: Buffer, start: string, end: string, kind: RemotePathProbe): string {
|
||||
if (output.length > MAX_PROBE_OUTPUT_BYTES) {
|
||||
throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
|
||||
}
|
||||
const text = output.toString("utf8");
|
||||
const startIndex = text.indexOf(start);
|
||||
const endIndex = startIndex < 0 ? -1 : text.indexOf(end, startIndex + start.length);
|
||||
if (startIndex < 0 || endIndex < 0 || text.indexOf(start, startIndex + start.length) >= 0) {
|
||||
throw new Error(`remote ${kind} probe returned an invalid framed response`);
|
||||
}
|
||||
const value = text.slice(startIndex + start.length, endIndex);
|
||||
if (!value.startsWith("/") || /[\0\r\n]/u.test(value)) {
|
||||
throw new Error(`remote ${kind} probe did not return one absolute POSIX path`);
|
||||
}
|
||||
return posixPath.normalize(value);
|
||||
}
|
||||
|
||||
export async function probeRemotePath(
|
||||
transport: RemoteTransport,
|
||||
kind: RemotePathProbe,
|
||||
cwd = ".",
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const token = randomBytes(12).toString("hex");
|
||||
const probe = probeCommand(kind, token);
|
||||
const chunks: Buffer[] = [];
|
||||
let captured = 0;
|
||||
let overflow = false;
|
||||
const result = await transport.exec(probe.command, cwd, {
|
||||
signal,
|
||||
timeout: PROBE_TIMEOUT_SECONDS,
|
||||
onData(data) {
|
||||
if (captured + data.length > MAX_PROBE_OUTPUT_BYTES) {
|
||||
overflow = true;
|
||||
return;
|
||||
}
|
||||
chunks.push(data);
|
||||
captured += data.length;
|
||||
},
|
||||
});
|
||||
const output = Buffer.concat(chunks);
|
||||
if (overflow) throw new Error(`remote ${kind} probe output exceeded ${MAX_PROBE_OUTPUT_BYTES} bytes`);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = output.toString("utf8").replace(/\s+/gu, " ").trim().slice(0, 300);
|
||||
throw new Error(`remote ${kind} probe failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
return parseRemotePathProbe(output, probe.start, probe.end, kind);
|
||||
}
|
||||
+145
-48
@@ -15,6 +15,7 @@ export interface RemoteGrepInput {
|
||||
literal?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
include?: string;
|
||||
includeHidden?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -30,6 +31,8 @@ const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
const MAX_LINE_CHARS = 800;
|
||||
const MAX_CAPTURE_CHARS = 512_000;
|
||||
const SEARCH_TIMEOUT_SECONDS = 30;
|
||||
const MAX_DIAGNOSTIC_CHARS = 16_000;
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
@@ -55,6 +58,7 @@ export function resolveRemoteSearchPath(path: string | undefined, remoteCwd: str
|
||||
validateField(value, "path");
|
||||
if (value === "~") return remoteHome;
|
||||
if (value.startsWith("~/")) return posixPath.normalize(posixPath.join(remoteHome, value.slice(2)));
|
||||
if (value.startsWith("~")) throw new Error("path supports only ~ or ~/... remote HOME expansion");
|
||||
if (value.startsWith("/")) return posixPath.normalize(value);
|
||||
return posixPath.normalize(posixPath.join(remoteCwd, value));
|
||||
}
|
||||
@@ -70,6 +74,27 @@ const STATUS_HELPER = [
|
||||
`}`,
|
||||
].join("\n");
|
||||
|
||||
const GREP_RECORD_HELPER = [
|
||||
`pi_ssh_limit_colon_records() {`,
|
||||
` local path match line content count=0`,
|
||||
` while IFS= read -r -d '' path && IFS= read -r match; do`,
|
||||
' line="${match%%:*}"',
|
||||
' content="${match#*:}"',
|
||||
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
|
||||
` count=$((count + 1))`,
|
||||
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
|
||||
` done`,
|
||||
`}`,
|
||||
`pi_ssh_limit_git_records() {`,
|
||||
` local path line content count=0`,
|
||||
` while IFS= read -r -d '' path && IFS= read -r -d '' line && IFS= read -r content; do`,
|
||||
' printf \'%s\\0%s\\0%s\\0\' "${path:0:' + MAX_LINE_CHARS + '}" "$line" "${content:0:' + MAX_LINE_CHARS + '}"',
|
||||
` count=$((count + 1))`,
|
||||
` if [ "$count" -ge "$PI_SSH_TAKE" ]; then return 0; fi`,
|
||||
` done`,
|
||||
`}`,
|
||||
] .join("\n");
|
||||
|
||||
function findPipeline(input: RemoteFindInput, root: string, limit: number): string {
|
||||
const pattern = validateField(input.pattern, "pattern") as string;
|
||||
const fdCase = input.caseSensitive ? "--case-sensitive" : "--ignore-case";
|
||||
@@ -81,24 +106,24 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
|
||||
STATUS_HELPER,
|
||||
`if command -v fd >/dev/null 2>&1; then`,
|
||||
` printf '${MARKER}fd\\n'`,
|
||||
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
` fd --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||
`elif command -v fdfind >/dev/null 2>&1; then`,
|
||||
` printf '${MARKER}fdfind\\n'`,
|
||||
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
` fdfind --type f --color never --fixed-strings ${fdCase} ${fdHidden} --exclude .git --exclude node_modules -- ${shellQuote(pattern)} ${shellQuote(root)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
|
||||
` printf '${MARKER}git-ls-files\\n'`,
|
||||
` git -C ${shellQuote(root)} ls-files -co --exclude-standard 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
` git -C ${shellQuote(root)} ls-files -co --exclude-standard | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
|
||||
' pi_ssh_accept_status "${statuses[2]}" 0 1 141 || exit $?',
|
||||
`else`,
|
||||
` printf '${MARKER}find\\n'`,
|
||||
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' 2>&1 | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' | ${hiddenFilter} | grep -F ${grepCase} -- ${shellQuote(pattern)} | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||
' pi_ssh_accept_status "${statuses[1]}" 0 1 141 || exit $?',
|
||||
@@ -110,29 +135,64 @@ function findPipeline(input: RemoteFindInput, root: string, limit: number): stri
|
||||
function grepPipeline(input: RemoteGrepInput, root: string, limit: number): string {
|
||||
const pattern = validateField(input.pattern, "pattern") as string;
|
||||
const include = validateField(input.include, "include", true);
|
||||
const fixed = input.literal === false ? "" : "-F";
|
||||
if (include?.includes("/")) throw new Error("include must be a basename glob without /");
|
||||
const rgMode = input.literal === false ? "" : "-F";
|
||||
const grepMode = input.literal === false ? "-E" : "-F";
|
||||
const insensitive = input.caseSensitive ? "" : "-i";
|
||||
const rgHidden = input.includeHidden
|
||||
? "--hidden"
|
||||
: "--glob '!.*' --glob '!**/.*' --glob '!**/.*/**'";
|
||||
const rgGlob = include ? `-g ${shellQuote(include)}` : "";
|
||||
const gitPath = include ? `-- ${shellQuote(include)}` : "";
|
||||
const gitPathspecs = [
|
||||
...(include ? [`:(glob)**/${include}`] : []),
|
||||
...(input.includeHidden ? [] : [":(exclude,glob)**/.*", ":(exclude,glob)**/.*/**"]),
|
||||
];
|
||||
const gitPath = gitPathspecs.length > 0 ? `-- ${gitPathspecs.map(shellQuote).join(" ")}` : "";
|
||||
const findName = include ? `-name ${shellQuote(include)}` : "";
|
||||
const includeHidden = input.includeHidden ? 1 : 0;
|
||||
const take = limit + 1;
|
||||
return [
|
||||
STATUS_HELPER,
|
||||
GREP_RECORD_HELPER,
|
||||
`PI_SSH_TAKE=${take}`,
|
||||
`PI_SSH_ROOT=${shellQuote(root)}`,
|
||||
`PI_SSH_INCLUDE_HIDDEN=${includeHidden}`,
|
||||
`if command -v rg >/dev/null 2>&1; then`,
|
||||
` printf '${MARKER}ripgrep\\n'`,
|
||||
` rg --line-number --no-heading --color never --with-filename --max-columns 500 --max-columns-preview ${fixed} ${insensitive} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
` rg --null --line-number --no-heading --color never --with-filename --max-columns ${MAX_LINE_CHARS} --max-columns-preview ${rgMode} ${insensitive} ${rgHidden} ${rgGlob} --glob '!.git/**' --glob '!node_modules/**' -- ${shellQuote(pattern)} ${shellQuote(root)} | pi_ssh_limit_colon_records`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
|
||||
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
|
||||
`elif command -v git >/dev/null 2>&1 && git -C ${shellQuote(root)} rev-parse --is-inside-work-tree >/dev/null 2>&1; then`,
|
||||
` printf '${MARKER}git-grep\\n'`,
|
||||
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -n -I ${fixed} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
` git -C ${shellQuote(root)} grep --untracked --exclude-standard -z -n -I ${grepMode} ${insensitive} -e ${shellQuote(pattern)} ${gitPath} | pi_ssh_limit_git_records`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 1 141 || exit $?',
|
||||
' pi_ssh_accept_status "${statuses[1]}" 0 || exit $?',
|
||||
`else`,
|
||||
` printf '${MARKER}grep\\n'`,
|
||||
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -exec grep -nH -I ${fixed} ${insensitive} -- ${shellQuote(pattern)} {} + 2>&1 | head -n ${take} | cut -c 1-${MAX_LINE_CHARS}`,
|
||||
` find ${shellQuote(root)} -type f ! -path '*/.git/*' ! -path '*/node_modules/*' ${findName} -print0 | (`,
|
||||
` while IFS= read -r -d '' file; do`,
|
||||
` if [ "$PI_SSH_INCLUDE_HIDDEN" -eq 0 ]; then`,
|
||||
` if [ -d "$PI_SSH_ROOT" ]; then`,
|
||||
' relative=${file#"$PI_SSH_ROOT"/}',
|
||||
` else`,
|
||||
' relative=${file##*/}',
|
||||
` fi`,
|
||||
' case "$relative" in .*|*/.*) continue ;; esac',
|
||||
` fi`,
|
||||
` grep -n -I ${grepMode} ${insensitive} -- ${shellQuote(pattern)} "$file" | while IFS= read -r match; do`,
|
||||
` printf '%s\\0%s\\n' "$file" "$match"`,
|
||||
` done`,
|
||||
' grep_statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${grep_statuses[0]}" 0 1 141 || exit $?',
|
||||
' pi_ssh_accept_status "${grep_statuses[1]}" 0 1 141 || exit $?',
|
||||
` done`,
|
||||
` ) | pi_ssh_limit_colon_records`,
|
||||
' statuses=("${PIPESTATUS[@]}")',
|
||||
' pi_ssh_accept_status "${statuses[0]}" 0 141 || exit $?',
|
||||
' pi_ssh_accept_status "${statuses[1]}" 0 141 || exit $?',
|
||||
' pi_ssh_accept_status "${statuses[2]}" 0 || exit $?',
|
||||
`fi`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -149,30 +209,54 @@ export function buildRemoteGrepCommand(input: RemoteGrepInput, root: string): {
|
||||
return { command: grepPipeline(input, root, limit), limit };
|
||||
}
|
||||
|
||||
function prefixGitPath(line: string, root: string, grep: boolean): string {
|
||||
if (line.startsWith("/") || line.startsWith("../")) return line;
|
||||
if (!grep) return posixPath.join(root, line);
|
||||
const separator = line.indexOf(":");
|
||||
if (separator < 1) return line;
|
||||
return `${posixPath.join(root, line.slice(0, separator))}${line.slice(separator)}`;
|
||||
function prefixGitPath(path: string, root: string): string {
|
||||
return path.startsWith("/") || path.startsWith("../") ? path : posixPath.join(root, path);
|
||||
}
|
||||
|
||||
function visibleField(value: string): string {
|
||||
return value.replace(/\r/gu, "\\r").replace(/\n/gu, "\\n");
|
||||
}
|
||||
|
||||
function normalizeDiagnostic(raw: Buffer): string {
|
||||
return raw
|
||||
.toString("utf8")
|
||||
.replace(/\0/gu, " ")
|
||||
.replace(new RegExp(`${MARKER}[^\\n]*`, "gu"), "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.slice(0, 300);
|
||||
}
|
||||
|
||||
export function formatRemoteSearchOutput(
|
||||
raw: string,
|
||||
raw: string | Buffer,
|
||||
root: string,
|
||||
limit: number,
|
||||
kind: "find" | "grep",
|
||||
): RemoteSearchResult {
|
||||
const lines = raw.replace(/\r\n?/gu, "\n").split("\n");
|
||||
const markerIndex = lines.findIndex((line) => line.startsWith(MARKER));
|
||||
if (markerIndex < 0) throw new Error(`remote ${kind} did not report a search backend`);
|
||||
const backend = lines[markerIndex].slice(MARKER.length).trim() || "unknown";
|
||||
const sourceRows = lines.slice(markerIndex + 1).filter((line) => line.length > 0);
|
||||
const output = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
|
||||
const markerIndex = output.indexOf(MARKER);
|
||||
const markerEnd = markerIndex < 0 ? -1 : output.indexOf("\n", markerIndex);
|
||||
if (markerIndex < 0 || markerEnd < 0) throw new Error(`remote ${kind} did not report a search backend`);
|
||||
const backend = output.slice(markerIndex + MARKER.length, markerEnd).trim() || "unknown";
|
||||
const payload = output.slice(markerEnd + 1);
|
||||
let sourceRows: string[];
|
||||
if (kind === "grep") {
|
||||
const fields = payload.length === 0 ? [] : payload.split("\0");
|
||||
if (fields.at(-1) === "") fields.pop();
|
||||
if (fields.length % 3 !== 0) throw new Error("remote grep returned a malformed or truncated NUL-delimited result");
|
||||
sourceRows = [];
|
||||
for (let index = 0; index < fields.length; index += 3) {
|
||||
const path = backend.startsWith("git-") ? prefixGitPath(fields[index], root) : fields[index];
|
||||
sourceRows.push(`${visibleField(path)}:${visibleField(fields[index + 1])}:${visibleField(fields[index + 2])}`);
|
||||
}
|
||||
} else {
|
||||
sourceRows = payload.replace(/\r\n?/gu, "\n").split("\n").filter((line) => line.length > 0);
|
||||
if (backend.startsWith("git-")) sourceRows = sourceRows.map((line) => prefixGitPath(line, root));
|
||||
}
|
||||
const truncated = sourceRows.length > limit;
|
||||
const rows = sourceRows.slice(0, limit).map((line) => {
|
||||
const normalized = backend.startsWith("git-") ? prefixGitPath(line, root, kind === "grep") : line;
|
||||
return normalized.length > MAX_LINE_CHARS ? `${normalized.slice(0, MAX_LINE_CHARS - 1)}…` : normalized;
|
||||
});
|
||||
const rows = sourceRows.slice(0, limit).map((line) =>
|
||||
line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS - 1)}…` : line,
|
||||
);
|
||||
const header = `Remote ${kind}: ${rows.length} result${rows.length === 1 ? "" : "s"} (backend: ${backend}, root: ${root}, truncated: ${truncated ? "yes" : "no"})`;
|
||||
return {
|
||||
text: rows.length > 0 ? `${header}\n\n${rows.join("\n")}` : `${header}\n\nNo matches found.`,
|
||||
@@ -185,37 +269,50 @@ export function formatRemoteSearchOutput(
|
||||
async function runRemoteSearch(
|
||||
transport: RemoteTransport,
|
||||
command: string,
|
||||
executionCwd: string,
|
||||
root: string,
|
||||
limit: number,
|
||||
kind: "find" | "grep",
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteSearchResult> {
|
||||
const chunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let captured = 0;
|
||||
const result = await transport.exec(command, root, {
|
||||
signal,
|
||||
timeout: 30,
|
||||
onData(data) {
|
||||
if (captured >= MAX_CAPTURE_CHARS) return;
|
||||
const remaining = MAX_CAPTURE_CHARS - captured;
|
||||
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
|
||||
chunks.push(chunk);
|
||||
captured += chunk.length;
|
||||
},
|
||||
});
|
||||
const output = Buffer.concat(chunks).toString("utf8");
|
||||
let stderrCaptured = 0;
|
||||
const capture = (target: Buffer[], data: Buffer, stderr = false) => {
|
||||
const current = stderr ? stderrCaptured : captured;
|
||||
const maximum = stderr ? MAX_DIAGNOSTIC_CHARS : MAX_CAPTURE_CHARS;
|
||||
if (current >= maximum) return;
|
||||
const remaining = maximum - current;
|
||||
const chunk = data.length > remaining ? data.subarray(0, remaining) : data;
|
||||
target.push(chunk);
|
||||
if (stderr) stderrCaptured += chunk.length;
|
||||
else captured += chunk.length;
|
||||
};
|
||||
let result: { exitCode: number | null };
|
||||
try {
|
||||
result = await transport.exec(command, executionCwd, {
|
||||
signal,
|
||||
timeout: SEARCH_TIMEOUT_SECONDS,
|
||||
onData(data) { capture(chunks, data); },
|
||||
onStderr(data) { capture(stderrChunks, data, true); },
|
||||
});
|
||||
} catch (error) {
|
||||
if (!signal?.aborted && /timed out/iu.test(error instanceof Error ? error.message : String(error))) {
|
||||
throw new Error(`remote ${kind} timed out after ${SEARCH_TIMEOUT_SECONDS}s (root: ${root}); narrow the remote path with ssh_find before retrying`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const output = Buffer.concat(chunks);
|
||||
const stderr = Buffer.concat(stderrChunks);
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = output
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split("\n")
|
||||
.filter((line) => !line.startsWith(MARKER))
|
||||
.join(" ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.slice(0, 300);
|
||||
const detail = normalizeDiagnostic(stderr.length > 0 ? stderr : output);
|
||||
throw new Error(`remote ${kind} failed${result.exitCode === null ? "" : ` with exit code ${result.exitCode}`}${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
return formatRemoteSearchOutput(output, root, limit, kind);
|
||||
const formatted = formatRemoteSearchOutput(output, root, limit, kind);
|
||||
const warning = normalizeDiagnostic(stderr);
|
||||
if (warning) formatted.text += `\n\nRemote warning: ${warning}`;
|
||||
return formatted;
|
||||
}
|
||||
|
||||
export function runRemoteFind(
|
||||
@@ -227,7 +324,7 @@ export function runRemoteFind(
|
||||
): Promise<RemoteSearchResult> {
|
||||
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
|
||||
const built = buildRemoteFindCommand(input, root);
|
||||
return runRemoteSearch(transport, built.command, root, built.limit, "find", signal);
|
||||
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "find", signal);
|
||||
}
|
||||
|
||||
export function runRemoteGrep(
|
||||
@@ -239,5 +336,5 @@ export function runRemoteGrep(
|
||||
): Promise<RemoteSearchResult> {
|
||||
const root = resolveRemoteSearchPath(input.path, remoteCwd, remoteHome);
|
||||
const built = buildRemoteGrepCommand(input, root);
|
||||
return runRemoteSearch(transport, built.command, root, built.limit, "grep", signal);
|
||||
return runRemoteSearch(transport, built.command, remoteCwd, root, built.limit, "grep", signal);
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ declare module "ssh2" {
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: Buffer | string;
|
||||
agent?: string;
|
||||
passphrase?: string;
|
||||
tryKeyboard?: boolean;
|
||||
readyTimeout?: number;
|
||||
|
||||
@@ -7,12 +7,13 @@ import { expandUserPath, type SshHostConfig } from "./config.ts";
|
||||
|
||||
export interface RemoteExecOptions {
|
||||
onData: (data: Buffer) => void;
|
||||
onStderr?: (data: Buffer) => void;
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface RemoteTransport {
|
||||
connect(): Promise<void>;
|
||||
connect(signal?: AbortSignal): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }>;
|
||||
capture(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number | null; output: Buffer }>;
|
||||
@@ -36,6 +37,66 @@ class CommandQueue {
|
||||
}
|
||||
}
|
||||
|
||||
interface SemaphoreWaiter {
|
||||
resolve: (release: () => void) => void;
|
||||
reject: (error: Error) => void;
|
||||
signal?: AbortSignal;
|
||||
onAbort?: () => void;
|
||||
}
|
||||
|
||||
export class AsyncSemaphore {
|
||||
private active = 0;
|
||||
private readonly waiters: SemaphoreWaiter[] = [];
|
||||
private readonly limit: number;
|
||||
|
||||
constructor(limit: number) {
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new Error("semaphore limit must be a positive integer");
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
acquire(signal?: AbortSignal): Promise<() => void> {
|
||||
if (signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
|
||||
if (this.active < this.limit) {
|
||||
this.active += 1;
|
||||
return Promise.resolve(this.createRelease());
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiter: SemaphoreWaiter = { resolve, reject, signal };
|
||||
waiter.onAbort = () => {
|
||||
const index = this.waiters.indexOf(waiter);
|
||||
if (index >= 0) this.waiters.splice(index, 1);
|
||||
reject(new Error("SSH command aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", waiter.onAbort, { once: true });
|
||||
this.waiters.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
private createRelease(): () => void {
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.active -= 1;
|
||||
this.dispatch();
|
||||
};
|
||||
}
|
||||
|
||||
private dispatch(): void {
|
||||
while (this.active < this.limit) {
|
||||
const waiter = this.waiters.shift();
|
||||
if (!waiter) return;
|
||||
if (waiter.onAbort) waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
||||
if (waiter.signal?.aborted) {
|
||||
waiter.reject(new Error("SSH command aborted"));
|
||||
continue;
|
||||
}
|
||||
this.active += 1;
|
||||
waiter.resolve(this.createRelease());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
@@ -67,9 +128,11 @@ function buildConnectConfig(host: SshHostConfig): ConnectConfig {
|
||||
if (host.auth.type === "password") {
|
||||
config.password = host.auth.password;
|
||||
config.tryKeyboard = host.auth.method !== "password";
|
||||
} else {
|
||||
} else if (host.auth.type === "private-key") {
|
||||
config.privateKey = readFileSync(expandUserPath(host.auth.identityFile));
|
||||
if (host.auth.passphrase) config.passphrase = host.auth.passphrase;
|
||||
} else {
|
||||
config.agent = host.auth.socketPath === "pageant" ? "pageant" : expandUserPath(host.auth.socketPath);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -80,7 +143,8 @@ function errorMessage(error: unknown): string {
|
||||
|
||||
export class Ssh2Transport implements RemoteTransport {
|
||||
private readonly client: Client;
|
||||
private readonly queue = new CommandQueue();
|
||||
private readonly sftpQueue = new CommandQueue();
|
||||
private readonly execSemaphore = new AsyncSemaphore(4);
|
||||
private connected = false;
|
||||
private disposed = false;
|
||||
private disconnectError: Error | null = null;
|
||||
@@ -92,14 +156,17 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
async connect(signal?: AbortSignal): Promise<void> {
|
||||
if (this.connected) return;
|
||||
if (this.disposed) throw new Error("SSH2 transport is disposed");
|
||||
if (signal?.aborted) throw new Error("SSH connection aborted");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
||||
const succeed = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
this.connected = true;
|
||||
resolve();
|
||||
};
|
||||
@@ -108,8 +175,17 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
this.disconnectError = normalized;
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(normalized);
|
||||
};
|
||||
const onAbort = () => {
|
||||
try {
|
||||
this.client.destroy();
|
||||
} catch {
|
||||
// client may already be closed
|
||||
}
|
||||
fail(new Error("SSH connection aborted"));
|
||||
};
|
||||
this.client.once("ready", succeed);
|
||||
this.client.on("error", fail);
|
||||
this.client.on("close", () => {
|
||||
@@ -122,6 +198,7 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : ""));
|
||||
});
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
this.client.connect(buildConnectConfig(this.host));
|
||||
} catch (error) {
|
||||
@@ -141,8 +218,13 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
if (!this.connected) throw this.disconnectError ?? new Error("SSH2 connection is not active");
|
||||
}
|
||||
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
return this.queue.enqueue(() => this.execUnqueued(command, cwd, options));
|
||||
async exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
const release = await this.execSemaphore.acquire(options.signal);
|
||||
try {
|
||||
return await this.execUnqueued(command, cwd, options);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
@@ -197,7 +279,10 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
}
|
||||
channel = stream;
|
||||
stream.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
|
||||
stream.stderr.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
|
||||
stream.stderr.on("data", (data: Buffer | string) => {
|
||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
(options.onStderr ?? options.onData)(chunk);
|
||||
});
|
||||
stream.once("error", fail);
|
||||
stream.once("close", (code: number | undefined) => {
|
||||
if (settled) return;
|
||||
@@ -225,7 +310,7 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
}
|
||||
|
||||
async readFile(remotePath: string): Promise<Buffer> {
|
||||
return this.queue.enqueue(async () => {
|
||||
return this.sftpQueue.enqueue(async () => {
|
||||
const sftp = await this.sftp();
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data));
|
||||
@@ -234,7 +319,7 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
}
|
||||
|
||||
private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise<void> {
|
||||
return this.queue.enqueue(async () => {
|
||||
return this.sftpQueue.enqueue(async () => {
|
||||
const sftp = await this.sftp();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sftp.open(remotePath, flags, (error, handle) => {
|
||||
@@ -267,7 +352,7 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
}
|
||||
|
||||
async mkdir(remoteDir: string): Promise<void> {
|
||||
return this.queue.enqueue(() => this.mkdirUnqueued(remoteDir));
|
||||
return this.sftpQueue.enqueue(() => this.mkdirUnqueued(remoteDir));
|
||||
}
|
||||
|
||||
private async mkdirUnqueued(remoteDir: string): Promise<void> {
|
||||
@@ -290,7 +375,7 @@ export class Ssh2Transport implements RemoteTransport {
|
||||
}
|
||||
|
||||
async writeFile(remotePath: string, content: Buffer): Promise<void> {
|
||||
return this.queue.enqueue(async () => {
|
||||
return this.sftpQueue.enqueue(async () => {
|
||||
const sftp = await this.sftp();
|
||||
await this.mkdirUnqueued(posixPath.dirname(remotePath));
|
||||
const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`;
|
||||
|
||||
@@ -2,10 +2,13 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
|
||||
import { getConfiguredHost, parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
|
||||
|
||||
test("defines the reviewed agent-controlled SSH connection tool", () => {
|
||||
assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect");
|
||||
assert.equal(SSH_CONNECT_TOOL_METADATA.executionMode, "sequential");
|
||||
assert.match(SSH_CONNECT_TOOL_METADATA.description, /separate step/);
|
||||
assert.match(SSH_CONNECT_TOOL_METADATA.description, /wait for success/);
|
||||
assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]);
|
||||
assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath);
|
||||
assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" });
|
||||
@@ -17,6 +20,32 @@ test("defines the reviewed agent-controlled SSH connection tool", () => {
|
||||
assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/);
|
||||
});
|
||||
|
||||
test("unknown hosts report bounded imported host ID alternatives", () => {
|
||||
const host = {
|
||||
hostName: "example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password" as const, password: "secret" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
|
||||
};
|
||||
assert.equal(getConfiguredHost({ version: 1, hosts: { "packaging-server": host } }, "packaging-server"), host);
|
||||
assert.throws(
|
||||
() => getConfiguredHost({ version: 1, hosts: { "packaging-server": host, "build-server": host } }, "connect-packaging-server"),
|
||||
/available imported host IDs: build-server, packaging-server/,
|
||||
);
|
||||
const manyHosts = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`host-${String(index).padStart(2, "0")}`, host]));
|
||||
assert.throws(
|
||||
() => getConfiguredHost({ version: 1, hosts: manyHosts }, "missing"),
|
||||
(error: unknown) => {
|
||||
assert.match(String(error), /host-00, host-01, host-02/);
|
||||
assert.match(String(error), /… \(\+2 more\)/);
|
||||
assert.doesNotMatch(String(error), /example\.test|secret/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.throws(() => getConfiguredHost({ version: 1, hosts: {} }, "missing"), /no hosts are imported/);
|
||||
});
|
||||
|
||||
test("removes manual and implicit SSH connection surfaces", async () => {
|
||||
const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { validatePiSshConfig } from "../src/config.ts";
|
||||
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
|
||||
import { effectiveIdentityAgent, effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
|
||||
import { fingerprintHostKey } from "../src/ssh2-transport.ts";
|
||||
|
||||
test("parses the effective ssh -G output including repeated identity files", () => {
|
||||
@@ -20,7 +21,19 @@ test("parses the effective ssh -G output including repeated identity files", ()
|
||||
assert.deepEqual(effectiveValues(parsed, "identityfile"), ["~/.ssh/first", "~/.ssh/second"]);
|
||||
});
|
||||
|
||||
test("validates password and private-key host definitions", () => {
|
||||
test("resolves IdentityAgent socket paths and SSH_AUTH_SOCK references", () => {
|
||||
assert.equal(
|
||||
effectiveIdentityAgent(parseSshG("identityagent /Users/test/.1password/agent.sock"), {}),
|
||||
"/Users/test/.1password/agent.sock",
|
||||
);
|
||||
assert.equal(
|
||||
effectiveIdentityAgent(parseSshG("identityagent $SSH_AUTH_SOCK"), { SSH_AUTH_SOCK: "/tmp/agent.sock" }),
|
||||
"/tmp/agent.sock",
|
||||
);
|
||||
assert.equal(effectiveIdentityAgent(parseSshG("identityagent none"), { SSH_AUTH_SOCK: "/tmp/agent.sock" }), undefined);
|
||||
});
|
||||
|
||||
test("validates password, private-key, and agent host definitions", () => {
|
||||
const password = validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
@@ -36,6 +49,20 @@ test("validates password and private-key host definitions", () => {
|
||||
});
|
||||
assert.equal(password.hosts.build.auth.type, "password");
|
||||
|
||||
const agent = validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
build: {
|
||||
hostName: "build.example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "agent", socketPath: "~/.1password/agent.sock" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(agent.hosts.build.auth, { type: "agent", socketPath: "~/.1password/agent.sock" });
|
||||
|
||||
assert.throws(() => validatePiSshConfig({
|
||||
version: 1,
|
||||
hosts: {
|
||||
@@ -43,11 +70,11 @@ test("validates password and private-key host definitions", () => {
|
||||
hostName: "build.example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password", password: "" },
|
||||
auth: { type: "agent", socketPath: "" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
|
||||
},
|
||||
},
|
||||
}), /password/);
|
||||
}), /socketPath/);
|
||||
});
|
||||
|
||||
test("formats SSH host keys as pinned SHA256 fingerprints", () => {
|
||||
@@ -61,3 +88,12 @@ test("formats SSH host keys as pinned SHA256 fingerprints", () => {
|
||||
fingerprint: `SHA256:${expected}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("configuration import validates remote HOME and cwd with framed probes", async () => {
|
||||
const source = await readFile(new URL("../scripts/ssh-config.mjs", import.meta.url), "utf8");
|
||||
assert.match(source, /probeRemotePath\(transport, "home"\)/);
|
||||
assert.match(source, /probeRemotePath\(transport, "cwd"\)/);
|
||||
assert.doesNotMatch(source, /transport\.capture\(/);
|
||||
assert.match(source, /Authentication \[agent\/key\/password\]/);
|
||||
assert.match(source, /effectiveIdentityAgent\(effective\)/);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ const connection: SshPermissionConnection = {
|
||||
remote: "packaging-server",
|
||||
port: 2222,
|
||||
remoteCwd: "/srv/build",
|
||||
remoteHome: "/home/builder",
|
||||
};
|
||||
|
||||
test("formats reviewed connection requests without exposing credentials", () => {
|
||||
@@ -59,19 +60,27 @@ test("formats reviewed connection requests without exposing credentials", () =>
|
||||
test("formats the SSH target and bounded operation details", () => {
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path 'src/main.ts', offset 5, limit 20",
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path '/srv/build/src/main.ts' (requested 'src/main.ts'), offset 5, limit 20",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_write", { path: "dist/a.txt", content: "one\ntwo" }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path 'dist/a.txt' (2 lines, 7 characters)",
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path '/srv/build/dist/a.txt' (requested 'dist/a.txt') (2 lines, 7 characters)",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_grep", { pattern: "TODO", path: "src", include: "*.ts", limit: 25 }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under 'src', for 'TODO', limit 25, file glob '*.ts'",
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under '/srv/build/src' (requested 'src'), for 'TODO', limit 25, file glob '*.ts'",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_cd", { path: "../release" }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; change the active remote cwd to '/srv/release' (requested '../release')",
|
||||
);
|
||||
assert.match(
|
||||
formatSshPermissionInput("ssh_read", { path: "~/logs/app.log" }, connection),
|
||||
/remote path '\/home\/builder\/logs\/app\.log' \(requested '~\/logs\/app\.log'\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("registers previews and disables local path extraction for remote file tools", () => {
|
||||
test("registers previews and disables local path extraction for remote path tools", () => {
|
||||
const { service, formatters, extractors } = makeService();
|
||||
const pi = makePi();
|
||||
const dispose = installSshPermissionIntegration(
|
||||
@@ -84,10 +93,11 @@ test("registers previews and disables local path extraction for remote file tool
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
|
||||
assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
|
||||
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
|
||||
assert.deepEqual([...extractors.keys()], ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
|
||||
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
|
||||
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
|
||||
assert.equal(extractors.get("ssh_cd")?.({ path: "/remote/release" }), undefined);
|
||||
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
|
||||
assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/);
|
||||
|
||||
@@ -108,8 +118,8 @@ test("registers when the permission service becomes ready and cleans up on shutd
|
||||
|
||||
published = service;
|
||||
pi.emitEvent("permissions:ready");
|
||||
assert.equal(formatters.size, 7);
|
||||
assert.equal(extractors.size, 5);
|
||||
assert.equal(formatters.size, 8);
|
||||
assert.equal(extractors.size, 6);
|
||||
|
||||
pi.emit("session_shutdown");
|
||||
assert.equal(formatters.size, 0);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRemoteBashOps } from "../src/remote-bash.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
class CapturingTransport implements RemoteTransport {
|
||||
calls: Array<{ command: string; cwd: string }> = [];
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
dispose(): Promise<void> { return Promise.resolve(); }
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.calls.push({ command, cwd });
|
||||
options.onData(Buffer.from("ok\n"));
|
||||
return Promise.resolve({ exitCode: 0 });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
test("ssh_bash always executes from the active remote cwd", async () => {
|
||||
const connection = { remoteCwd: "/srv/project" };
|
||||
const transport = new CapturingTransport();
|
||||
const operations = createRemoteBashOps(connection, transport);
|
||||
|
||||
await operations.exec("pwd", "/Users/local/project", { onData() {} });
|
||||
assert.deepEqual(transport.calls, [{ command: "pwd", cwd: "/srv/project" }]);
|
||||
|
||||
connection.remoteCwd = "/opt/next-project";
|
||||
await operations.exec("npm test", "/another/local/path", { onData() {} });
|
||||
assert.deepEqual(transport.calls[1], { command: "npm test", cwd: "/opt/next-project" });
|
||||
});
|
||||
|
||||
test("ssh_bash rejects an invalid non-absolute remote cwd", () => {
|
||||
const operations = createRemoteBashOps({ remoteCwd: "relative/path" }, new CapturingTransport());
|
||||
assert.throws(
|
||||
() => operations.exec("pwd", "/Users/local/project", { onData() {} }),
|
||||
/requires an absolute remote cwd/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRemoteBashOps } from "../src/remote-bash.ts";
|
||||
import {
|
||||
changeRemoteCwd,
|
||||
mapLocalPathToRemote,
|
||||
resolveRemoteCwd,
|
||||
SSH_CD_EXECUTION_MODE,
|
||||
} from "../src/remote-cwd.ts";
|
||||
import { resolveRemoteSearchPath } from "../src/remote-search.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
class CapturingTransport implements RemoteTransport {
|
||||
calls: Array<{ command: string; cwd: string }> = [];
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
dispose(): Promise<void> { return Promise.resolve(); }
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.calls.push({ command, cwd });
|
||||
options.onData(Buffer.from("ok\n"));
|
||||
return Promise.resolve({ exitCode: 0 });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
test("resolves explicit remote workspace changes", () => {
|
||||
assert.equal(resolveRemoteCwd("services/api", "/srv/project", "/home/build"), "/srv/project/services/api");
|
||||
assert.equal(resolveRemoteCwd("../shared", "/srv/project", "/home/build"), "/srv/shared");
|
||||
assert.equal(resolveRemoteCwd("/opt/app/../release", "/srv/project", "/home/build"), "/opt/release");
|
||||
assert.equal(resolveRemoteCwd("~", "/srv/project", "/home/build"), "/home/build");
|
||||
assert.equal(resolveRemoteCwd("~/jobs/app", "/srv/project", "/home/build"), "/home/build/jobs/app");
|
||||
});
|
||||
|
||||
test("rejects invalid remote workspace paths", () => {
|
||||
assert.throws(() => resolveRemoteCwd("", "/srv/project", "/home/build"), /non-empty/);
|
||||
assert.throws(() => resolveRemoteCwd("bad\npath", "/srv/project", "/home/build"), /NUL or newline/);
|
||||
assert.throws(() => resolveRemoteCwd("~other/project", "/srv/project", "/home/build"), /only '~' or '~\/'/);
|
||||
});
|
||||
|
||||
test("ssh_cd is a sequential workspace transition", () => {
|
||||
assert.equal(SSH_CD_EXECUTION_MODE, "sequential");
|
||||
});
|
||||
|
||||
test("a validated workspace change drives subsequent remote tools", async () => {
|
||||
const connection = {
|
||||
remoteCwd: "/srv/project",
|
||||
remoteHome: "/home/build",
|
||||
localCwd: "/Users/local/project",
|
||||
localHome: "/Users/local",
|
||||
};
|
||||
const verified: string[] = [];
|
||||
|
||||
const changed = await changeRemoteCwd(connection, "../release", async (requestedCwd) => {
|
||||
verified.push(requestedCwd);
|
||||
return requestedCwd;
|
||||
});
|
||||
|
||||
assert.deepEqual(verified, ["/srv/release"]);
|
||||
assert.deepEqual(changed, { previousCwd: "/srv/project", remoteCwd: "/srv/release" });
|
||||
assert.equal(connection.remoteCwd, "/srv/release");
|
||||
assert.equal(mapLocalPathToRemote("/Users/local/project/logs/build.log", connection), "/srv/release/logs/build.log");
|
||||
assert.equal(resolveRemoteSearchPath(undefined, connection.remoteCwd, connection.remoteHome), "/srv/release");
|
||||
|
||||
const transport = new CapturingTransport();
|
||||
const operations = createRemoteBashOps(connection, transport);
|
||||
await operations.exec("npm test", "/Users/local/project", { onData() {} });
|
||||
assert.deepEqual(transport.calls, [{ command: "npm test", cwd: "/srv/release" }]);
|
||||
});
|
||||
|
||||
test("a failed workspace validation leaves the previous cwd active", async () => {
|
||||
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
|
||||
|
||||
await assert.rejects(
|
||||
changeRemoteCwd(connection, "missing", async () => {
|
||||
throw new Error("not a directory");
|
||||
}),
|
||||
/not a directory/,
|
||||
);
|
||||
assert.equal(connection.remoteCwd, "/srv/project");
|
||||
});
|
||||
|
||||
test("successive workspace changes resolve from the latest confirmed cwd", async () => {
|
||||
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
|
||||
const verify = async (requestedCwd: string) => requestedCwd;
|
||||
|
||||
await changeRemoteCwd(connection, "services/api", verify);
|
||||
await changeRemoteCwd(connection, "../worker", verify);
|
||||
|
||||
assert.equal(connection.remoteCwd, "/srv/project/services/worker");
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseRemotePathProbe, probeRemotePath } from "../src/remote-probe.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
class ProbeTransport implements RemoteTransport {
|
||||
command = "";
|
||||
cwd = "";
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
dispose(): Promise<void> { return Promise.resolve(); }
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.command = command;
|
||||
this.cwd = cwd;
|
||||
const start = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_START__)'/)?.[1];
|
||||
const end = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_END__)'/)?.[1];
|
||||
if (!start || !end) throw new Error("probe markers missing");
|
||||
options.onData(Buffer.from(`login banner\n${start}/srv/project${end}\nlogout banner\n`));
|
||||
return Promise.resolve({ exitCode: 0 });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
test("extracts one framed absolute path while ignoring shell startup output", async () => {
|
||||
const transport = new ProbeTransport();
|
||||
assert.equal(await probeRemotePath(transport, "cwd", "/srv"), "/srv/project");
|
||||
assert.equal(transport.cwd, "/srv");
|
||||
assert.match(transport.command, /pwd -P/);
|
||||
});
|
||||
|
||||
test("rejects unframed, relative, multiline, and oversized path probes", () => {
|
||||
assert.throws(() => parseRemotePathProbe(Buffer.from("/srv"), "START", "END", "cwd"), /invalid framed/);
|
||||
assert.throws(() => parseRemotePathProbe(Buffer.from("STARTrelativeEND"), "START", "END", "cwd"), /absolute POSIX path/);
|
||||
assert.throws(() => parseRemotePathProbe(Buffer.from("START/srv\notherEND"), "START", "END", "cwd"), /absolute POSIX path/);
|
||||
assert.throws(
|
||||
() => parseRemotePathProbe(Buffer.alloc(64 * 1024 + 1), "START", "END", "cwd"),
|
||||
/exceeded 65536 bytes/,
|
||||
);
|
||||
});
|
||||
|
||||
test("forwards cancellation to the probe exec call", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const transport = new ProbeTransport();
|
||||
const original = transport.exec.bind(transport);
|
||||
transport.exec = (command, cwd, options) => {
|
||||
assert.equal(options.signal, controller.signal);
|
||||
if (options.signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
|
||||
return original(command, cwd, options);
|
||||
};
|
||||
await assert.rejects(probeRemotePath(transport, "home", ".", controller.signal), /SSH command aborted/);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -14,13 +14,20 @@ import {
|
||||
} from "../src/remote-search.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
function grepProtocol(backend: string, rows: Array<[string, number, string]> = []): Buffer {
|
||||
const fields = rows.flatMap(([path, line, content]) => [path, String(line), content]);
|
||||
return Buffer.from(`__PI_SSH_SEARCH_BACKEND__:${backend}\n${fields.length > 0 ? `${fields.join("\0")}\0` : ""}`);
|
||||
}
|
||||
|
||||
class SearchTransport implements RemoteTransport {
|
||||
command = "";
|
||||
cwd = "";
|
||||
private readonly output: string;
|
||||
private readonly output: Buffer;
|
||||
private readonly stderr: Buffer;
|
||||
private readonly exitCode: number | null;
|
||||
constructor(output: string, exitCode: number | null = 0) {
|
||||
this.output = output;
|
||||
constructor(output: string | Buffer, exitCode: number | null = 0, stderr = "") {
|
||||
this.output = Buffer.isBuffer(output) ? output : Buffer.from(output);
|
||||
this.stderr = Buffer.from(stderr);
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
@@ -28,7 +35,8 @@ class SearchTransport implements RemoteTransport {
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.command = command;
|
||||
this.cwd = cwd;
|
||||
options.onData(Buffer.from(this.output));
|
||||
options.onData(this.output);
|
||||
if (this.stderr.length > 0) (options.onStderr ?? options.onData)(this.stderr);
|
||||
return Promise.resolve({ exitCode: this.exitCode });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
@@ -40,14 +48,30 @@ class SearchTransport implements RemoteTransport {
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
function toolPath(name: string): string {
|
||||
return execFileSync("/bin/sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function toolBin(root: string, names: string[]): string {
|
||||
const bin = join(root, `bin-${names.join("-")}`);
|
||||
mkdirSync(bin);
|
||||
for (const name of names) symlinkSync(toolPath(name), join(bin, name));
|
||||
return bin;
|
||||
}
|
||||
|
||||
function execute(command: string, cwd: string, path: string) {
|
||||
return spawnSync("/bin/bash", ["-c", command], { cwd, env: { ...process.env, PATH: path } });
|
||||
}
|
||||
|
||||
test("resolves remote search roots without using local filesystem semantics", () => {
|
||||
assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app");
|
||||
assert.equal(resolveRemoteSearchPath("src", "/srv/app", "/home/build"), "/srv/app/src");
|
||||
assert.equal(resolveRemoteSearchPath("~/logs", "/srv/app", "/home/build"), "/home/build/logs");
|
||||
assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log");
|
||||
assert.throws(() => resolveRemoteSearchPath("~other/project", "/srv/app", "/home/build"), /only ~ or ~\//);
|
||||
});
|
||||
|
||||
test("builds bounded capability-adaptive commands with shell-quoted user input", () => {
|
||||
test("builds bounded capability-adaptive commands with unified grep semantics", () => {
|
||||
const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir");
|
||||
assert.match(find.command, /command -v fd/);
|
||||
assert.match(find.command, /git-ls-files/);
|
||||
@@ -55,18 +79,20 @@ test("builds bounded capability-adaptive commands with shell-quoted user input",
|
||||
assert.match(find.command, /'it'"'"'s-app'/);
|
||||
assert.match(find.command, /'\/srv\/app dir'/);
|
||||
|
||||
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 20 }, "/srv/app");
|
||||
assert.match(grep.command, /command -v rg/);
|
||||
assert.match(grep.command, /git-grep/);
|
||||
assert.match(grep.command, /find .* -exec grep/);
|
||||
assert.match(grep.command, /head -n 21/);
|
||||
const grep = buildRemoteGrepCommand({ pattern: "needle+", literal: false, include: "*.ts", includeHidden: true, limit: 20 }, "/srv/app");
|
||||
assert.match(grep.command, /rg --null/);
|
||||
assert.match(grep.command, /git .* grep .* -z .* -E/);
|
||||
assert.match(grep.command, /grep -n -I -E/);
|
||||
assert.match(grep.command, /PI_SSH_TAKE=21/);
|
||||
assert.match(grep.command, /--hidden/);
|
||||
assert.throws(() => buildRemoteGrepCommand({ pattern: "x", include: "sub\/*.ts" }, "/srv/app"), /basename glob without \//);
|
||||
assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/);
|
||||
assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/);
|
||||
});
|
||||
|
||||
test("normalizes git paths, truncates rows, and reports the backend", () => {
|
||||
test("normalizes NUL-delimited git paths, escaped newlines, truncation, and backend", () => {
|
||||
const result = formatRemoteSearchOutput(
|
||||
"__PI_SSH_SEARCH_BACKEND__:git-grep\nsrc/a.ts:2:TODO\nsrc/b.ts:3:TODO\nsrc/c.ts:4:TODO\n",
|
||||
grepProtocol("git-grep", [["src/a.ts", 2, "TODO"], ["src/line\nb.ts", 3, "TODO"], ["src/c.ts", 4, "TODO"]]),
|
||||
"/srv/app",
|
||||
2,
|
||||
"grep",
|
||||
@@ -75,25 +101,39 @@ test("normalizes git paths, truncates rows, and reports the backend", () => {
|
||||
assert.equal(result.matchCount, 2);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/);
|
||||
assert.match(result.text, /src\/line\\nb\.ts:3:TODO/);
|
||||
assert.doesNotMatch(result.text, /src\/c\.ts/);
|
||||
});
|
||||
|
||||
test("executes remote find through the structured transport with bounded output", async () => {
|
||||
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/srv/app/a.ts\n");
|
||||
const result = await runRemoteFind(transport, { pattern: "a", limit: 5 }, "/srv/app", "/home/build");
|
||||
test("executes remote find from the active workspace while searching a separate root", async () => {
|
||||
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/data/search/a.ts\n");
|
||||
const result = await runRemoteFind(transport, { pattern: "a", path: "/data/search", limit: 5 }, "/srv/app", "/home/build");
|
||||
assert.equal(transport.cwd, "/srv/app");
|
||||
assert.match(transport.command, /command -v fd/);
|
||||
assert.match(transport.command, /\/data\/search/);
|
||||
assert.equal(result.matchCount, 1);
|
||||
assert.match(result.text, /backend: fd/);
|
||||
});
|
||||
|
||||
test("propagates backend failures without exposing the internal marker", async () => {
|
||||
const transport = new SearchTransport(
|
||||
"__PI_SSH_SEARCH_BACKEND__:git-grep\nfatal: invalid regular expression\n",
|
||||
128,
|
||||
);
|
||||
test("searches a single remote file without treating the file as execution cwd", async () => {
|
||||
const transport = new SearchTransport(grepProtocol("ripgrep", [["/var/log/application.log", 7, "ERROR"]]));
|
||||
const result = await runRemoteGrep(transport, { pattern: "ERROR", path: "/var/log/application.log" }, "/srv/app", "/home/build");
|
||||
assert.equal(transport.cwd, "/srv/app");
|
||||
assert.match(transport.command, /\/var\/log\/application\.log/);
|
||||
assert.equal(result.matchCount, 1);
|
||||
assert.match(result.text, /application\.log:7:ERROR/);
|
||||
});
|
||||
|
||||
test("keeps successful stderr warnings out of match rows and uses stderr for failures", async () => {
|
||||
const warningTransport = new SearchTransport(grepProtocol("ripgrep"), 0, "warning: skipped socket\n");
|
||||
const warningResult = await runRemoteGrep(warningTransport, { pattern: "ABSENT" }, "/srv/app", "/home/build");
|
||||
assert.equal(warningResult.matchCount, 0);
|
||||
assert.match(warningResult.text, /No matches found/);
|
||||
assert.match(warningResult.text, /Remote warning: warning: skipped socket/);
|
||||
|
||||
const failed = new SearchTransport(grepProtocol("git-grep"), 128, "fatal: invalid regular expression\n");
|
||||
await assert.rejects(
|
||||
runRemoteGrep(transport, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
|
||||
runRemoteGrep(failed, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
|
||||
(error: unknown) => {
|
||||
assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/);
|
||||
assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/);
|
||||
@@ -102,49 +142,100 @@ test("propagates backend failures without exposing the internal marker", async (
|
||||
);
|
||||
});
|
||||
|
||||
test("adaptive commands preserve no-match success and propagate real backend errors", async () => {
|
||||
test("reports actionable bounded-search timeout guidance", async () => {
|
||||
const transport = new SearchTransport("");
|
||||
transport.exec = () => Promise.reject(new Error("SSH command timed out after 30s"));
|
||||
await assert.rejects(
|
||||
runRemoteGrep(transport, { pattern: "TODO", path: "/home/build" }, "/srv/app", "/home/build"),
|
||||
/remote grep timed out after 30s \(root: \/home\/build\); narrow the remote path with ssh_find/,
|
||||
);
|
||||
});
|
||||
|
||||
test("forces git-grep and fallback no-match paths and preserves real errors", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-"));
|
||||
try {
|
||||
mkdirSync(join(root, "src"));
|
||||
writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8");
|
||||
execFileSync("/usr/bin/git", ["init", "-q", root]);
|
||||
execFileSync("/usr/bin/git", ["-C", root, "add", "src/a.ts"]);
|
||||
const environment = { ...process.env, PATH: "/usr/bin:/bin" };
|
||||
const execute = (command: string) => execFileSync("/bin/bash", ["-c", command], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: environment,
|
||||
});
|
||||
|
||||
const find = buildRemoteFindCommand({ pattern: "a.ts", limit: 5 }, root);
|
||||
const findResult = formatRemoteSearchOutput(execute(find.command), root, find.limit, "find");
|
||||
assert.equal(findResult.backend, "git-ls-files");
|
||||
assert.match(findResult.text, /src\/a\.ts/);
|
||||
execFileSync(toolPath("git"), ["init", "-q", root]);
|
||||
execFileSync(toolPath("git"), ["-C", root, "add", "src/a.ts"]);
|
||||
const gitPath = toolBin(root, ["git"]);
|
||||
const fallbackPath = toolBin(root, ["find", "grep"]);
|
||||
|
||||
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root);
|
||||
const grepResult = formatRemoteSearchOutput(execute(grep.command), root, grep.limit, "grep");
|
||||
assert.equal(grepResult.backend, "git-grep");
|
||||
assert.match(grepResult.text, /src\/a\.ts:1:/);
|
||||
const gitProcess = execute(grep.command, root, gitPath);
|
||||
assert.equal(gitProcess.status, 0, gitProcess.stderr.toString());
|
||||
const gitResult = formatRemoteSearchOutput(gitProcess.stdout, root, grep.limit, "grep");
|
||||
assert.equal(gitResult.backend, "git-grep");
|
||||
assert.equal(gitResult.matchCount, 1);
|
||||
|
||||
const fallbackProcess = execute(grep.command, root, fallbackPath);
|
||||
assert.equal(fallbackProcess.status, 0, fallbackProcess.stderr.toString());
|
||||
const fallbackResult = formatRemoteSearchOutput(fallbackProcess.stdout, root, grep.limit, "grep");
|
||||
assert.equal(fallbackResult.backend, "grep");
|
||||
assert.equal(fallbackResult.matchCount, 1);
|
||||
|
||||
const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root);
|
||||
const noMatchProcess = spawnSync("/bin/bash", ["-c", noMatch.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
assert.equal(noMatchProcess.status, 0);
|
||||
assert.equal(formatRemoteSearchOutput(noMatchProcess.stdout, root, noMatch.limit, "grep").matchCount, 0);
|
||||
for (const path of [gitPath, fallbackPath]) {
|
||||
const process = execute(noMatch.command, root, path);
|
||||
assert.equal(process.status, 0, process.stderr.toString());
|
||||
assert.equal(formatRemoteSearchOutput(process.stdout, root, noMatch.limit, "grep").matchCount, 0);
|
||||
}
|
||||
|
||||
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
|
||||
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
const invalidProcess = execute(invalidRegex.command, root, fallbackPath);
|
||||
assert.notEqual(invalidProcess.status, 0);
|
||||
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/i);
|
||||
assert.match(invalidProcess.stderr.toString(), /regular expression|bracket/i);
|
||||
|
||||
const missingRoot = join(root, "missing");
|
||||
const missing = buildRemoteFindCommand({ pattern: "anything" }, missingRoot);
|
||||
const missingProcess = spawnSync("/bin/bash", ["-c", missing.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
const missing = buildRemoteFindCommand({ pattern: "anything" }, join(root, "missing"));
|
||||
const missingProcess = execute(missing.command, root, fallbackPath);
|
||||
assert.notEqual(missingProcess.status, 0);
|
||||
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i);
|
||||
assert.match(missingProcess.stderr.toString(), /find|No such file|not found/i);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new SearchTransport(execute(grep.command));
|
||||
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root);
|
||||
assert.equal(throughTransport.matchCount, 1);
|
||||
test("rg, git-grep, and fallback agree on hidden files, basename globs, and portable ERE", (context) => {
|
||||
let rg: string;
|
||||
try { rg = toolPath("rg"); } catch { context.skip("rg is unavailable"); return; }
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-matrix-"));
|
||||
try {
|
||||
mkdirSync(join(root, "sub"));
|
||||
mkdirSync(join(root, ".secret"));
|
||||
for (const path of ["visible.ts", "sub/nested.ts", ".hidden.ts", ".secret/deep.ts"]) {
|
||||
writeFileSync(join(root, path), "needle\nneedlee\n", "utf8");
|
||||
}
|
||||
execFileSync(toolPath("git"), ["init", "-q", root]);
|
||||
execFileSync(toolPath("git"), ["-C", root, "add", "."]);
|
||||
const paths = [toolBin(root, ["rg"]), toolBin(root, ["git"]), toolBin(root, ["find", "grep"])];
|
||||
assert.equal(toolPath("rg"), rg);
|
||||
|
||||
const runCounts = (input: Parameters<typeof buildRemoteGrepCommand>[0]) => paths.map((path) => {
|
||||
const built = buildRemoteGrepCommand(input, root);
|
||||
const process = execute(built.command, root, path);
|
||||
assert.equal(process.status, 0, process.stderr.toString());
|
||||
return formatRemoteSearchOutput(process.stdout, root, built.limit, "grep").matchCount;
|
||||
});
|
||||
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts" }), [4, 4, 4]);
|
||||
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts", includeHidden: true }), [8, 8, 8]);
|
||||
assert.deepEqual(runCounts({ pattern: "needle+", literal: false, include: "*.ts" }), [4, 4, 4]);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fallback NUL protocol keeps newline filenames as one bounded result", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-newline-"));
|
||||
try {
|
||||
const filename = "line\nbreak.ts";
|
||||
writeFileSync(join(root, filename), "needle\n", "utf8");
|
||||
const fallbackPath = toolBin(root, ["find", "grep"]);
|
||||
const built = buildRemoteGrepCommand({ pattern: "needle", include: "*.ts" }, root);
|
||||
const process = execute(built.command, root, fallbackPath);
|
||||
assert.equal(process.status, 0, process.stderr.toString());
|
||||
const result = formatRemoteSearchOutput(process.stdout, root, built.limit, "grep");
|
||||
assert.equal(result.matchCount, 1);
|
||||
assert.match(result.text, /line\\nbreak\.ts:1:needle/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -162,17 +253,17 @@ test("propagates fd and ripgrep failures while preserving ripgrep no-match", ()
|
||||
const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` };
|
||||
|
||||
const find = buildRemoteFindCommand({ pattern: "anything" }, root);
|
||||
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, env: environment });
|
||||
assert.equal(failedFind.status, 3);
|
||||
assert.match(failedFind.stdout, /fd exploded/);
|
||||
assert.match(failedFind.stderr.toString(), /fd exploded/);
|
||||
|
||||
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
|
||||
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
|
||||
assert.equal(failedGrep.status, 2);
|
||||
assert.match(failedGrep.stdout, /rg exploded/);
|
||||
assert.match(failedGrep.stderr.toString(), /rg exploded/);
|
||||
|
||||
writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
|
||||
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
|
||||
assert.equal(noMatch.status, 0);
|
||||
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
|
||||
@@ -182,12 +182,15 @@ test("connects with password auth, pins the host key, and streams exec output",
|
||||
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
|
||||
|
||||
const output: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const result = await transport.exec("printf ok", "/srv/build", {
|
||||
onData: (data) => output.push(data),
|
||||
onStderr: (data) => stderr.push(data),
|
||||
timeout: 5,
|
||||
});
|
||||
assert.equal(result.exitCode, 7);
|
||||
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n");
|
||||
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\n");
|
||||
assert.equal(Buffer.concat(stderr).toString("utf8"), "stderr\n");
|
||||
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
|
||||
await transport.dispose();
|
||||
assert.equal(fake.ended, true);
|
||||
@@ -232,6 +235,18 @@ test("loads private-key authentication and forwards the passphrase", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("forwards SSH agent authentication through ssh2", async () => {
|
||||
const host: SshHostConfig = {
|
||||
...passwordHost(),
|
||||
auth: { type: "agent", socketPath: "~/.1password/agent.sock" },
|
||||
};
|
||||
const { fake, transport } = await connectedTransport(undefined, host);
|
||||
assert.equal(fake.connectConfig?.agent, join(homedir(), ".1password", "agent.sock"));
|
||||
assert.equal(fake.connectConfig?.password, undefined);
|
||||
assert.equal(fake.connectConfig?.privateKey, undefined);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
|
||||
const sftp = new FakeSftp();
|
||||
sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));
|
||||
@@ -279,6 +294,64 @@ test("falls back to direct overwrite when SFTP v3 rename cannot replace", async
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("aborts an in-progress SSH connection attempt", async () => {
|
||||
const fake = new FakeClient();
|
||||
fake.connectAction = () => {};
|
||||
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||
const controller = new AbortController();
|
||||
const connecting = transport.connect(controller.signal);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await assert.rejects(connecting, /SSH connection aborted/);
|
||||
assert.equal(fake.destroyed, true);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("allows four independent exec channels and bounds additional commands", async () => {
|
||||
const fake = new FakeClient();
|
||||
const channels: FakeChannel[] = [];
|
||||
fake.execAction = (_command, callback) => {
|
||||
const channel = new FakeChannel();
|
||||
channels.push(channel);
|
||||
callback(undefined, channel as ClientChannel);
|
||||
};
|
||||
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||
await transport.connect();
|
||||
const commands = Array.from({ length: 5 }, (_, index) =>
|
||||
transport.exec(`command-${index}`, "/srv", { onData() {}, timeout: 0 }),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(channels.length, 4);
|
||||
channels[0]?.emit("close", 0);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(channels.length, 5);
|
||||
for (const channel of channels.slice(1)) channel.emit("close", 0);
|
||||
await Promise.all(commands);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("cancels an exec while it is waiting for a concurrency slot", async () => {
|
||||
const fake = new FakeClient();
|
||||
const channels: FakeChannel[] = [];
|
||||
fake.execAction = (_command, callback) => {
|
||||
const channel = new FakeChannel();
|
||||
channels.push(channel);
|
||||
callback(undefined, channel as ClientChannel);
|
||||
};
|
||||
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||
await transport.connect();
|
||||
const active = Array.from({ length: 4 }, () => transport.exec("sleep", "/srv", { onData() {}, timeout: 0 }));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const controller = new AbortController();
|
||||
const queued = transport.exec("queued", "/srv", { onData() {}, timeout: 0, signal: controller.signal });
|
||||
controller.abort();
|
||||
await assert.rejects(queued, /SSH command aborted/);
|
||||
assert.equal(channels.length, 4);
|
||||
for (const channel of channels) channel.emit("close", 0);
|
||||
await Promise.all(active);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("aborts and times out commands by closing the active channel", async () => {
|
||||
const abortClient = new FakeClient();
|
||||
const abortChannel = new FakeChannel();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
plan/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
.temp/
|
||||
.pi/readcache
|
||||
.claude
|
||||
.vscode
|
||||
coverage/
|
||||
.gitnexus
|
||||
.pi/subagents.json
|
||||
|
||||
# Pi internal files
|
||||
progress.md
|
||||
CLAUDE.md
|
||||
@@ -0,0 +1,33 @@
|
||||
# Development files
|
||||
tsconfig.json
|
||||
.vscode/
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
PLAN.md
|
||||
SUMMARY.md
|
||||
plan/
|
||||
media/
|
||||
|
||||
# Internal contributor/agent instructions — not for distribution
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
# Build artifacts
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
|
||||
# Tests (if any)
|
||||
test/
|
||||
tests/
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
|
||||
.pi
|
||||
.claude
|
||||
.vscode
|
||||
.github/
|
||||
biome.json
|
||||
coverage/
|
||||
@@ -0,0 +1,103 @@
|
||||
# Development Rules
|
||||
|
||||
## Conversational Style
|
||||
|
||||
- Keep answers short and concise
|
||||
- No emojis in issues, PR comments, or code
|
||||
- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!")
|
||||
- Technical prose only, be direct
|
||||
- When the user asks a question, answer it first before making edits or running implementation commands.
|
||||
- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Read files in full before wide-ranging changes, before editing files you have not fully inspected, and when asked to investigate or audit. Do not rely on search snippets for broad changes.
|
||||
- No `any` unless absolutely necessary.
|
||||
- Inline single-line helpers that have only one call site.
|
||||
- Check `node_modules` for external API types (`@earendil-works/pi-*`, `@sinclair/typebox`, etc.); don't guess.
|
||||
- **No inline imports** (`await import()`, `import("pkg").Type`, dynamic type imports). Top-level imports only.
|
||||
- Never remove or downgrade code to fix type errors from outdated deps; upgrade the dep instead.
|
||||
- Match the surrounding code style — it is enforced by biome (`biome.json`).
|
||||
- Always ask before removing functionality or code that appears intentional.
|
||||
- Do not preserve backward compatibility unless the user asks for it.
|
||||
- This is a pi extension. Respect the Claude Code-compatible tool names, calling conventions, and UI patterns the extension deliberately mirrors; don't diverge from them without a stated reason.
|
||||
- When reviewing a diff, favor solutions that are elegant, not overengineered — flag needless abstraction, layering, or defensive code that the change doesn't warrant.
|
||||
|
||||
## Commands
|
||||
|
||||
- After code changes (not docs), run the full check suite and fix all errors and warnings:
|
||||
```bash
|
||||
npm run lint # biome
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test # vitest run
|
||||
```
|
||||
- `npm run lint:fix` auto-fixes most style issues.
|
||||
- `npm run test` runs the whole suite, including `*-e2e.test.ts` files. To iterate on a single file, run it directly: `npx vitest run test/<file>.test.ts`.
|
||||
- If you create or modify a test file, run it and iterate on the test or implementation until it passes.
|
||||
- `npm run build` compiles with `tsc`; run it only when verifying the build output or when requested.
|
||||
- For ad-hoc scripts, write them to a temp file (e.g. `/tmp`), run, edit if needed, remove when done. Don't embed multi-line scripts in `bash` commands.
|
||||
|
||||
## Git
|
||||
|
||||
- **Never commit.** The user commits manually. At most, suggest a concise commit message as text.
|
||||
- **Never push**, tag, or create branches unless the user explicitly asks.
|
||||
- Never run history- or worktree-destroying commands: `git reset --hard`, `git checkout .`, `git clean -fd`, `git stash`, `git add -A`, `git add .`, `git commit --no-verify`, or any force push.
|
||||
- Leave the working tree as the user left it — don't stage, stash, or revert files you didn't change.
|
||||
|
||||
## Issues and PRs
|
||||
|
||||
See `CONTRIBUTING.md` for the contributor guidelines and quality bar.
|
||||
|
||||
When reviewing PRs:
|
||||
|
||||
- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks.
|
||||
- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches.
|
||||
- If you need PR file contents, fetch/read them into temporary files or use `git show <ref>:<path>` without switching branches.
|
||||
|
||||
When posting issue/PR comments:
|
||||
|
||||
- Write the comment to a temp file and post with `gh issue/pr comment --body-file` (never multi-line markdown via `--body`).
|
||||
- Keep comments concise, technical, and in the user's tone.
|
||||
|
||||
## Changelog
|
||||
|
||||
Location: `CHANGELOG.md` (single file, [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format).
|
||||
|
||||
- All new entries go under `## [Unreleased]`, in the right subsection (`### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Security`, `### Refactored`). Read the section first and append to existing subsections; never duplicate them.
|
||||
- One bullet per issue/PR. Never combine separate issues or pull requests into a single entry, even when they touch the same or similar components. (A PR together with the issue it closes or that diagnosed it is one change — one bullet citing both.)
|
||||
- Breaking changes are not a separate subsection. Call them out with a `> **⚠️ Breaking: …**` blockquote at the top of the version section, and/or a bold `**BREAKING:**` bullet under `### Changed`, with a migration note.
|
||||
- Entries are concise — a bold lead-in stating what changed, then a sentence or two on why it changed and anything a user must do about it. Aim for 2–4 sentences; a genuinely intricate change may run longer, but length is never the goal. Do not match the density of older entries, several of which are far too long.
|
||||
- Cut what the reader doesn't need: narration of the investigation, alternatives considered and rejected, restatements of the diff, and detail recoverable from the code or the linked issue. Name a file or symbol only when it helps someone find the change.
|
||||
- Released version sections (e.g. `## [0.12.0]`) are immutable; never modify them.
|
||||
- Attribute external contributions: `... ([#456](https://github.com/tintinweb/pi-subagents/pull/456) — thanks [@username](https://github.com/username))`.
|
||||
|
||||
## Releasing
|
||||
|
||||
**Versioning** (all releases are `0.x`, no major bumps):
|
||||
|
||||
- `minor` (`0.x.0`) — a notable new feature, or any breaking change.
|
||||
- `patch` (`0.x.y`) — bug fixes and smaller additions.
|
||||
|
||||
Before a release:
|
||||
|
||||
- Update `CHANGELOG.md` — move the `## [Unreleased]` entries under a new `## [X.Y.Z]` version section, and add a fresh empty `## [Unreleased]` for the next cycle.
|
||||
- Update `README.md` if user-facing behavior changed (features list, settings, usage).
|
||||
- Run the full check suite plus the e2e tests, and fix anything that fails:
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test:e2e # faux/scripted e2e — no network, no keys
|
||||
npm run build
|
||||
```
|
||||
- For a real pre-publish smoke test, run the **live** e2e against an actual model:
|
||||
```bash
|
||||
PI_E2E_LIVE=1 npm run test:e2e # uses your local `pi` login; optional PI_PROVIDER / PI_MODEL
|
||||
```
|
||||
`PI_E2E_LIVE=1` swaps the scripted faux suite for the live one (the faux suite is `skipIf(LIVE)`).
|
||||
(`prepublishOnly` runs lint + typecheck + test + build; the live e2e is the smoke test to run by hand before publishing.)
|
||||
|
||||
**Never publish.** The user runs `npm version` / `npm publish` and any tagging manually. Do not run those commands unless the user explicitly asks.
|
||||
|
||||
## User Override
|
||||
|
||||
If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions.
|
||||
@@ -0,0 +1,752 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Native `my-pi` permission child lifecycle bridge.** Child sessions now publish `subagents:child:session-created` synchronously before `bindExtensions()` and `subagents:child:disposed` after actual teardown. Every descendant keeps the interactive root session as its permission authority, and child prompts replace inherited identity tags with their stable agent key. This lets `pi-permission-system` apply root whole-session grants to child-fixed access facts, run auto-review against the trusted root transcript, and forward unresolved `ask` decisions to the root UI without treating model-generated child prompts as direct user authorization.
|
||||
- **Host-owned mandatory extensions with fail-closed loading.** `createPiSubagentsExtension({ mandatoryExtensionPaths })` lets a bundle protect exact canonical extension entries from Agent-controlled `extensions: false`, `isolated`, and `exclude_extensions`. Missing or substituted entries abort before session creation. Mandatory handlers bind without automatically surfacing their tools, preserving the separation between security lifecycle and Agent tool visibility.
|
||||
|
||||
### Changed
|
||||
- **Enabled in the `my-pi` bundle.** The root package now installs this source through `file:./pi-subagents`, loads the host-owned wrapper by default, and assigns the three orchestration tools to Tool Search's checked-in `subagents` group. Pi host peers are optional in this maintained package so the bundle continues to use the host runtime instead of installing a duplicate. The off-screen mention clone now disables extension/resource discovery so Tool Search and other active-tool owners cannot remove its single synthetic `Agent` tool during `session_start`.
|
||||
|
||||
## [0.18.0] - 2026-08-20
|
||||
|
||||
> **⚠️ Breaking — an `Agent` call that doesn't say now runs in the background** (`backgroundByDefault`). Following Claude Code, where the agent backgrounds unless the caller passes `run_in_background: false`. An unqualified spawn no longer blocks the turn and no longer returns the agent's output inline: it returns an ID immediately, and the completion notification carries a preview (500 chars solo, 300 grouped) with the full text behind `get_subagent_result`. Set `backgroundByDefault: false` to restore the previous behaviour globally; an explicit `run_in_background` on the call or in frontmatter overrides the setting in both directions. **Nested spawns are unchanged** — an agent spawning its own agent still defaults to foreground, because a detached child is stopped by `abortOwnedChildren` when its parent settles and has no notification path of its own. Two consequences worth planning for: `maxConcurrent` now applies to nearly every spawn where foreground used to bypass it (its default is raised 4 → 10 to compensate), and Esc interrupts the turn without stopping the agents it started — stop those from `/agents → Running agents`, which is also how Claude Code separates the two.
|
||||
|
||||
### Added
|
||||
- **`reportUsage` — subagent tokens and cost count towards this session's own totals** ([#193](https://github.com/tintinweb/pi-subagents/issues/193), [#203](https://github.com/tintinweb/pi-subagents/issues/203) — thanks [@johnstegeman](https://github.com/johnstegeman) and [@unrelentingfox](https://github.com/unrelentingfox)). Subagents run in their own pi sessions, so nothing they spent reached the parent's `getSessionStats()`: a session that delegated most of its work read as nearly free in the footer, the statusline and `/cost`. With the setting on, each `Agent` / `get_subagent_result` / `steer_subagent` result carries the spend accumulated since the last one, which pi folds into the session totals and attributes to `/cost`'s "Tools/summaries" bucket. Off by default — it changes numbers you may already be tracking. Agents that finish in the background have no result of their own to ride on, so their spend lands on the next call rather than the moment they finish; nested agents are covered, counted once each, on the top-level result. Cost is pi's own per-message figure, and the context-window percentage is unaffected. Implemented independently in [#202](https://github.com/tintinweb/pi-subagents/pull/202), whose `cacheRead` reasoning corrected this one: the cached prefix is genuinely re-read and re-billed per call, and pi counts it that way for the session's own messages, so the report includes it even though the extension's own token displays still exclude it ([#38](https://github.com/tintinweb/pi-subagents/issues/38)).
|
||||
- **`showCost` — an estimated cost beside subagent token counts** ([#194](https://github.com/tintinweb/pi-subagents/pull/194) — thanks [@daanzu](https://github.com/daanzu), whose PR is the display design here). Shown in the widget, FleetView, the conversation viewer, foreground results, `get_subagent_result` and completion notifications, with a batch total when several background agents finish together. Off by default, and shown only when there is a cost to show: a model pi has no pricing data for reports zero, and `$0.00` beside its tokens would say the run was measured and found free rather than never measured. Figures keep cents at minimum and four decimals at most (`~$0.0042`, `~$1.24`), because rounding everything to cents printed one number for runs that differed fourfold.
|
||||
- **Lifecycle events carry the run's spend as a pi `Usage`** ([#137](https://github.com/tintinweb/pi-subagents/issues/137), [#138](https://github.com/tintinweb/pi-subagents/pull/138) — thanks [@leset0ng](https://github.com/leset0ng)). `subagents:completed` and `subagents:failed` gain a `usage` field alongside `tokens`, carrying the token components — `cacheRead` included — and `cost.total` in USD. It is pi's own `Usage`, the shape pi puts on `ToolResultEvent` and `AssistantMessage`, so the money is where a listener already looks and anything pi adds to `Usage` needs no change here. Omitted when nothing was spent, so *spent nothing* stays distinguishable from *never ran*. `tokens` is unchanged and still the display total, which excludes `cacheRead` ([#38](https://github.com/tintinweb/pi-subagents/issues/38)) — the two answer different questions and neither derives from the other. This is the aggregate only — the per-message `{timestamp, provider, model, usage}` records those asked for, and the per-model attribution they enable, remain out of scope (#137 was declined and #138 withdrawn by its author).
|
||||
|
||||
### Changed
|
||||
- **The pi peer floor moves from `>=0.80.0` to `>=0.81.0`.** `reportUsage` is built on pi folding `toolResult.usage` into `getSessionStats()`, which pi only started doing in 0.81.0 — every 0.80.x sums assistant messages alone and drops the field, so the setting would have been present and silently inert there. The CI floor job installs 0.81.0 and runs the suite against it, which is what caught this; the e2e that pins the behaviour runs unconditionally rather than skipping, so the range cannot quietly stop being true. Nothing else in the extension needed 0.81.
|
||||
- **Top-level `Agent` spawns default to background, behind a new `backgroundByDefault` setting** (see the breaking note above). Configurable at `/agents → Settings → Background by default` and in `subagents.json`; applied live. Nested spawns pass `false` unconditionally and ignore the setting.
|
||||
- **The background concurrency limit's default is raised from 4 to 10.** Foreground agents bypass the `maxConcurrent` pool entirely, so while foreground was the default a six-way fan-out ran six; with background as the default every top-level agent takes a slot, and a limit of 4 would have silently queued the tail of exactly the parallel fan-outs the `Agent` tool description tells the model to send. Still configurable — the ceiling is unchanged.
|
||||
- **The agent-file writer emits `run_in_background: false` rather than dropping it.** It wrote the field only when truthy, which was lossless while omission and `false` meant the same thing; under `backgroundByDefault` they no longer do. No current path reaches it — `serializeAgentFile`'s only caller is Eject, which is offered for built-in defaults, and those omit the field — so this is the writer half of the format keeping pace with the loader, not a fix for observable behaviour.
|
||||
|
||||
### Fixed
|
||||
- **Subagent sessions now close the extension lifecycle they open** ([#242](https://github.com/tintinweb/pi-subagents/issues/242) — thanks [@bsv9](https://github.com/bsv9)). Every child session binds extensions so `session_start` fires and they can set up per-session state, but nothing emitted the matching `session_shutdown`: `AgentSession.dispose()` only invalidates the `ExtensionRunner`. Whatever an extension armed in `session_start` — timers, fs watchers, sockets, temp dirs — therefore leaked one set per spawn, teardown a `session_shutdown` handler owns (killing runs started by that child, say) never ran, and the 10-minute record sweep turned the leak into a crash: disposing invalidated the runner while a leaked timer was still armed, so its next tick threw `assertActive()` from a bare `Timeout._onTimeout` — an `uncaughtException` that took interactive pi down with it. Both eviction and quit now emit `session_shutdown` (reason `quit`) before disposing, which is what pi itself does in `AgentSessionRuntime.dispose()`. Quit waits for those handlers rather than exiting past them, bounded at three seconds per session so one that hangs cannot strand you at a torn-down TUI; a session whose extensions register no handler is untouched and still disposes synchronously. Independently fixed in [#228](https://github.com/tintinweb/pi-subagents/pull/228) and [#170](https://github.com/tintinweb/pi-subagents/pull/170) — thanks [@v-kuskov](https://github.com/v-kuskov) and [@liu-qingyuan](https://github.com/liu-qingyuan).
|
||||
- **The `Agent` tool no longer tells the model that foreground agents run one at a time** ([#232](https://github.com/tintinweb/pi-subagents/issues/232) — thanks [@willfenton](https://github.com/willfenton)). *"Foreground calls run sequentially — only one executes at a time"* was never true: pi's agent loop dispatches a message's tool calls through `Promise.all` unless the whole batch opts out via `toolExecution: "sequential"` or some tool in it declares `executionMode: "sequential"`, and this extension sets neither — two foreground `Agent` calls in one message start within microseconds of each other. Nothing serialized them at this layer either; `agent-manager` deliberately exempts foreground agents from the `maxConcurrent` queue, since they block the parent anyway. The claim was self-inflicted rather than inherited: `d0cb511` replaced a correct bullet with it, and the later upstream-alignment pass grafted it onto the end of Claude Code's real sentence instead of replacing it, which also left an invented `with run_in_background: true on each` qualifier in the middle — upstream says a single message with multiple tool uses runs concurrently, full stop. Both are gone, restoring upstream's wording (minus its build-validator/test-runner example, consistent with the examples already omitted for token cost). The cost of the error was steering: an orchestrator that wanted parallelism was pushed into background spawns it did not need, paying a queue slot and a notification round-trip for concurrency it already had. Investigating where the sentence came from is what surfaced the default divergence in the breaking note above, so the two ship together — the remaining foreground/background prose is now Claude Code's own, including the `Don't race` bullet that only earns its place once background is the default.
|
||||
- **Scheduled and RPC-spawned agents show their token counts in the widget, and finished agents keep theirs in the conversation viewer.** Both surfaces read spend from the live activity tracker, which only `Agent`-tool spawns get and which is deleted the moment an agent finishes — so those agents rendered with no token stats at all. Every surface now reads it from the agent's record instead: the record is the only total that outlives the run and the only one a nested child's spend is folded into, so the figure no longer jumped upward at completion as the read switched from one to the other. The tracker keeps what is genuinely live — tool activity, turn count, context percentage — and no longer accumulates a second copy of the totals. Spotted in [#194](https://github.com/tintinweb/pi-subagents/pull/194) — thanks [@daanzu](https://github.com/daanzu).
|
||||
- **Agents started outside the `Agent` tool say what they are doing, instead of `thinking…` for their whole run** ([#181](https://github.com/tintinweb/pi-subagents/pull/181) — thanks [@xz-dev](https://github.com/xz-dev)). The widget's activity line and turn counter come from an activity tracker that only the `Agent` tool handler created, so an agent started through cross-extension RPC (the path `TaskExecute` uses), through an `@handle` mention, or through the `Symbol.for("pi-subagents:manager")` registry showed a permanent `thinking…` while the same row's tool-use count climbed beside it and the conversation viewer showed the real work. The tracker now belongs to the one funnel all three spawn paths pass through, so none can be missed and none can supply half-wired callbacks of its own; the `Agent` tool reaches the manager directly, so nothing is double-tracked. Its turn ceiling is resolved exactly the way the run resolves the limit it enforces — explicit value, else the agent's own `max_turns`, else the project default — rather than read off the caller's options, which a mention deliberately omits so the agent file can decide: the row would otherwise render `↻3` where the tool renders `↻3≤20`. Scheduled jobs still spawn through the manager and so still render without per-tool detail.
|
||||
|
||||
## [0.17.1] - 2026-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A `model`-mode mention now actually reaches the agent it starts.** The off-screen clone was built with an empty tool allowlist, which stripped its own `Agent` tool, so every start fell back to a direct spawn with a warning. Fixing that surfaced a second bug behind it: the clone spawned in the foreground, answering into a session disposed moments later, so the agent ran and reached nobody. Its spawn is now forced to the background.
|
||||
- **`@` offers files again.** Handle rows are merged into pi's own suggestion list instead of replacing it — the provider asked pi only when no agent matched, and prefix matching meant an empty token matched every handle, so a bare `@` listed no files at all.
|
||||
|
||||
## [0.17.0] - 2026-08-17
|
||||
|
||||
> **⚠️ Note — an agent file's frontmatter `name:` now substitutes for the filename as its `subagent_type`.** Following Claude Code, the declared name is the dispatch identity and the filename is only the fallback, so `blubb.md` with `name: code-review` is spawned, mentioned and listed as `code-review`. Any value is accepted except one containing `:`, which Claude Code reserves for plugin scoping; such a file — like any unparseable one — is skipped with a warning rather than loaded under a name nothing honours, and `strictAgentFiles` turns that into a startup failure.
|
||||
|
||||
> **⚠️ Breaking — subagent sessions now persist to disk by default** (`rememberAgents`). Transcripts that used to be in-memory are written to the session dir and appear nested under their spawner in pi's `/resume`. Per-agent `persist_session:` overrides it in both directions — `false` keeps that agent in memory, `true` persists it even with the setting off. Set `rememberAgents: false` to restore the previous behaviour globally.
|
||||
|
||||
### Added
|
||||
- **Subagents are addressable from the prompt: `@handle message` goes to that agent instead of the main model.** Reaching one previously meant spending a main-model turn on `steer_subagent`, or walking the FleetView. The handle names the agent, not one process, so a running or queued agent is messaged, a finished one is resumed in the background, and one that never ran is started — the reply arrives as the ordinary background-completion notification either way. Every agent gets a handle from its type, numbered on collision (`explore`, `explore-2`), offered by `@` completion alongside pi's file completion. Only a leading `@handle` followed by a message is a send, so a bare `@explore`, a mid-sentence mention and `@src/index.ts` still reach the model; toggle with `agentMentions`.
|
||||
- **Mentioning an agent that isn't running now starts it through an off-screen clone of the conversation.** A throwaway copy of the session takes a turn holding only the `Agent` tool, so the model writes the agent's prompt from the conversation instead of forwarding your typed line — the context-aware prompt Claude Code gets from its own mention flow, without a visible turn narrating a decision the handle already made. Nothing enters the chat but a `Starting @plan…` toast, and what starts is an ordinary top-level agent with a transcript, widget detail and a handle. `agentMentions` accordingly takes `"model"` (default), `"direct"` — the previous behaviour, started from your text with no model call — or `"off"`; the old booleans still read as `"model"`/`"off"`. Messaging a running agent and resuming a finished one are direct in both modes, a clone that cannot run falls back to a direct start rather than losing the mention, and `"model"` lifts the TUI-only restriction for starts, so `pi -p '@plan the migration'` now works.
|
||||
- **`@handle` keeps working after the agent's record is gone.** Handles used to expire with the in-memory record ~10 minutes past completion, silently flipping `@explore anything else?` from *resume* to *start fresh*. An evicted agent now leaves a tombstone and the mention reopens its session from disk (requires `rememberAgents`, below). Only the definition is re-resolved, so a continuation runs under the type's current frontmatter — and a resume whose type has since been deleted or disabled is refused rather than falling back to another agent. Tombstones cap at 100 and clear on `/new` and session switch.
|
||||
- **The model can `name` an agent, and names work wherever ids do.** `Agent` takes an optional `name`, so an agent can be `@auth-audit` instead of leaving you to tell `@explore-2` from `@explore-3`. Naming is additive — the type-derived handle is still assigned, and both draw from one namespace, so neither can shadow the other. `steer_subagent` and `get_subagent_result` now accept a handle as well as an id, ids first, so existing calls are unchanged.
|
||||
- **`@main <message>` forces text to the main model; `@agent-<type>` is a synonym.** `main` is reserved and can never be allocated to an agent, so a leading `@main` is stripped and the rest passes through with its attachments — the escape hatch for text that only looks like a mention. `@agent-explore fix it` is Claude Code's manual spelling; the handle as written wins, so an agent actually called `agent-explore` stays reachable. Handles cap at 64 characters.
|
||||
- **`worktreeIsolation` project setting — turn worktree isolation off for a whole repo** ([#184](https://github.com/tintinweb/pi-subagents/issues/184) — thanks [@tinysnake](https://github.com/tinysnake)). On a repo large enough that every worktree costs real time and disk, the only defence was asking the model not to request one. Set `"worktreeIsolation": false` in `subagents.json` (or `/agents → Settings → Worktree isolation`) and the `Agent` tool's `isolation` parameter is dropped from the schema entirely, along with the usage-note bullet describing it and the `/agents` generator's frontmatter field — the same trade `schedule` already makes when scheduling is disabled, and necessary, since prose left behind teaches the model to pass a parameter that is no longer declared and to report a branch that was never created. The refusal is enforced below the tool boundary as well, so it also covers agent files, scheduled jobs and the cross-extension RPC path, and a requested worktree is downgraded to a normal run rather than failing the call. Default is unchanged (`true`); the parameter and its prose appear or disappear on the next pi session, since both are built at tool registration, while the refusal applies immediately.
|
||||
|
||||
### Fixed
|
||||
- **The `Agent` tool no longer tells the model a worktree's path is returned in the result.** It never was: `cleanupWorktree` removes the worktree directory before returning and the record keeps only `{ hasChanges, branch }`, so a model acting on the promised path would have been sent to a deleted directory. The neighbouring claim that the worktree is "automatically cleaned up if the agent makes no changes" was misleading in the same way — the directory is removed in both cases; what is conditional is whether a branch survives. Both had been in the tool description since worktree isolation shipped, inherited from Claude Code's wording; the README's completion summary said the same thing loosely and has been corrected too.
|
||||
- **`isolation` accepts `"off"`, so a model that fills every optional parameter can decline a worktree** ([#231](https://github.com/tintinweb/pi-subagents/issues/231), [#184](https://github.com/tintinweb/pi-subagents/issues/184), via [#201](https://github.com/tintinweb/pi-subagents/pull/201) — thanks [@Munsen](https://github.com/Munsen)). As a single-value optional literal, `isolation` was the one parameter whose only expressible value had an expensive side effect, and every other optional field has an inert filler — so a model that fills everything in reflexively ended up reviewing a staged diff inside a fresh copy of the repo that contained none of the work, over and over, against its own stated intent to omit the parameter. `"off"` is now a legal value — listed first and described as the default — and is also accepted in agent frontmatter (`none`, `no` and `false` too), where it becomes a genuine veto: frontmatter outranks tool-call parameters, so it refuses a worktree even when the caller asks for one, which no value could previously do. The parameter description now also warns that a worktree cannot see uncommitted or staged work, the specific trap here. One behaviour change to note: an agent file already carrying `isolation: none`/`no`/`false` used to parse to nothing at all, leaving a caller free to supply `worktree` anyway; it now parses to `off` and refuses one.
|
||||
|
||||
### Changed
|
||||
- **Frontmatter `name:` is now the agent's *type* rather than a display-label alias** (see the note above). `display_name:` still sets the label, and a file setting only `name:` badges as that name, since the label falls back to the type. A file whose `name:` differs from its filename changes `subagent_type`; rename the field to `display_name:` to keep the old identity.
|
||||
- **Subagent sessions are persisted by default (`rememberAgents`)** (see the note above). They previously ran in-memory unless the agent file set `persist_session: true`, which left nothing for a handle to reopen once the record was evicted — the reason handles expired. Nested children are excluded, since nothing can address them and so nothing could reopen their transcript.
|
||||
- **The selected FleetView row renders in the theme's primary `text` color** ([#230](https://github.com/tintinweb/pi-subagents/issues/230), [#234](https://github.com/tintinweb/pi-subagents/pull/234) — thanks [@kunaaal13](https://github.com/kunaaal13)). Agent name, description and the elapsed/token stats previously kept their muted/dim tokens while selected, so the highlighted row read as several emphasis levels rather than one selection; the `●` marker keeps its accent. A configured agent color still badges on the selected row and is bolded there, matching Claude Code's own FleetView — the color is what identifies the row you are reading, and leaving the badge in place keeps the row's width fixed as the selection moves.
|
||||
|
||||
## [0.16.1] - 2026-08-15
|
||||
|
||||
### Changed
|
||||
- **The pi compile baseline is declared instead of inferred.** The `@earendil-works/pi-*` packages were peers only, so 0.80.6 came from npm's peer auto-install and the committed lockfile rather than from any decision — the emergent pin the 0.14.0 entry below had to diagnose. They are now exact devDependencies, pinned to the current release (`0.84.2`); the peer range stays `>=0.80.0` and consumers are unaffected.
|
||||
- **`compat-latest-pi` now runs typecheck and the full suite** instead of two hand-picked e2e files, so drift can't hide in an unlisted test — against pi 0.84.2 the job reported green while typecheck and four e2e suites were failing. Still `continue-on-error`, but both steps pass as of the new baseline, so a failure there is now fresh breakage rather than known debt.
|
||||
- **The suite runs against pi 0.84.2, so the `modelRegistry` → `modelRuntime` migration is exercised by default rather than only by a `continue-on-error` job.** Two places had been carrying it as known-broken: the `modelRuntime` compat shim in `src/agent-runner.ts` typechecked only pre-migration (an opaque `unknown` read off the private facade field can't satisfy the newer `ModelRuntime` parameter type), and the e2e faux backend faked auth through a structural `modelRegistry` that post-migration pi no longer accepts, so every faux run died with "No API key found for faux". The faux registry and its post-migration counterpart — auth via `getAuth`/`hasConfiguredAuth`, the turn itself streaming through `modelRuntime.streamSimple` — now live together in `test/helpers/faux-model-backend.ts`, and both are passed to `createAgentSession`, each pi ignoring the option it doesn't know.
|
||||
- **CI covers the floor of the declared peer range, not just the pin.** `>=0.80.0` used to be tested incidentally, because the inferred pin *was* a 0.80.x; moving the baseline to 0.84.2 would have left the range asserting support for versions nothing built against. A `compat-floor-pi` job now installs 0.80.1 — the earliest published release satisfying the range — and runs typecheck plus the full suite. Unlike the latest-pi canary it is blocking: that run is the only evidence behind the range, and if the floor becomes unsupportable the peer range is what should change.
|
||||
- **`publishConfig.access` is set to `public`**, so publishing this scoped package no longer depends on `--access public` or on the access already set from a prior publish.
|
||||
|
||||
|
||||
## [0.16.0] - 2026-08-14
|
||||
|
||||
### Added
|
||||
- **Custom agents can set `name` and `color`, so agent files shared with Claude Code keep their display identity** ([#216](https://github.com/tintinweb/pi-subagents/issues/216) — thanks [@HerbertGao](https://github.com/HerbertGao)). `name:` is accepted as a display-name fallback (`display_name:` still wins, and the filename still determines the `subagent_type` and dispatch identity), and `color:` renders the agent name as a badge — the color is the background — in the `Agent` tool header, the widget, FleetView, and the conversation viewer. Claude Code's eight color names render with Claude Code's own values; six-digit hex and the Agency Agents palette aliases are accepted too, quantized to the xterm-256 palette on non-truecolor terminals the way pi's own theme does. A missing or invalid color preserves each surface's existing theme styling. Badge text is black or white by WCAG contrast against the rendered background, where Claude Code uses a single inverse color for every badge. In the `Agent` tool header the line paints the tool block's row tint itself and leaves it open: the badge has to close its own background, so it restores that tint, and the TUI pads a line to width *after* rendering it, so closing the line would leave that padding untinted. The tint is painted only when there is a badge, so an uncolored agent's header renders exactly as it did before.
|
||||
- **`run_in_background` now works on `resume`, so continuing an agent no longer blocks the session** ([#214](https://github.com/tintinweb/pi-subagents/pull/214) — thanks [@akram-ahrardi](https://github.com/akram-ahrardi)). The `Agent` tool's resume branch returned before its background branch, so the flag was accepted and silently ignored: every resume ran inline and held the main loop for the whole turn, however long that agent took. A resume with `run_in_background: true` now detaches exactly like a background spawn — it returns immediately, queues behind `maxConcurrent`, streams to the widget and FleetView, and notifies on completion — while a resume without the flag still runs inline and returns its result, unchanged. Detaching required the differences from a spawn to be handled rather than inherited: the run is *not* wired to the tool-call abort signal (a background spawn omits it too, and forwarding it would kill the agent when the parent turn is interrupted while spawns from that same turn kept going); a second resume of an agent whose run is still in flight is refused instead of replacing the live run's abort controller, which would have put it beyond the reach of `/agents` stop; the transcript is appended to rather than truncated, since the path is deterministic per agent and the previous run's turns — including any the session has since compacted away — are what the completion notification points at; output streaming is wired when the run actually starts, so a resume stopped while still queued leaves no subscription behind; and the `subagents:created` event reports the record's own type, since resume ignores `subagent_type` and a mirror keyed by id would otherwise re-register the agent under a type it never had. The widget's finished-agent age is cleared on resume, or an agent that had already completed once would vanish instead of showing the new run's completion line.
|
||||
|
||||
### Fixed
|
||||
- **RPC-spawned agents now appear in the live Widget and FleetView while they are running** ([#224](https://github.com/tintinweb/pi-subagents/pull/224) — thanks [@dereknex](https://github.com/dereknex)). Cross-extension RPC calls entered through `AgentManager.spawn()` instead of the `Agent` tool handler, but only that handler started the Widget/Fleet refresh timers. The manager still tracked RPC agents and the completion callback refreshed the UI, so a long-running RPC agent was invisible until it finished and could appear only as a terminal row. The shared manager-start callback now starts and refreshes both native surfaces for every top-level spawn source, including RPC and scheduled agents; nested agents remain intentionally hidden.
|
||||
|
||||
### Documentation
|
||||
- **The cross-extension RPC spawn example named an option that does not exist**. It passed `run_in_background: true`, the `Agent` *tool's* parameter name; the RPC handler forwards `options` verbatim to `AgentManager.spawn`, whose field is `isBackground`, so the flag was silently dropped — an RPC agent nobody meant to exempt never occupied a `maxConcurrent` slot and never queued behind one, and `subagents:created` reported no background flag for it. The example now passes `isBackground`, which the RPC test suite has always pinned, and the surrounding text says what the flag does and does not change: every RPC spawn returns its id immediately and runs detached either way. It also states the visibility rule that changed above, so the widget/FleetView behavior is documented where callers read about spawning rather than only in this file.
|
||||
|
||||
## [0.15.2] - 2026-08-14
|
||||
|
||||
### Documentation
|
||||
- **Three README statements corrected against the code they describe**, each now pinned by a test so the doc and the behavior can't drift apart again. (1) Scheduling was documented as forcing `run_in_background` to `true`; it does not — an explicit `run_in_background: false` alongside `schedule` is *refused*, and always has been. The 0.10.0 entry below carries the same error: `git log -S` shows the refusal shipped in the very commit that introduced scheduling, so both documents were written from the design sketch and never matched what was released. Refusing is also the intended behavior — silently flipping a parameter the caller explicitly set is the failure mode [#37](https://github.com/tintinweb/pi-subagents/issues/37) was filed about. (2) The completion-notification example showed a `transcript: .pi/output/agent-abc123.jsonl` line; that path was replaced by the OS-tmpdir layout in [#146](https://github.com/tintinweb/pi-subagents/pull/146) and the example was never updated, so it contradicted the paragraph six lines above it. (3) The `skills` frontmatter row read as though naming skills preloaded them *in addition to* the parent's; naming any list turns skill inheritance off and the agent gets only the named ones.
|
||||
|
||||
### Fixed
|
||||
- **`/agents → Create agent` produced an unloadable agent when the description contained a colon**. The wizard interpolated the description and the custom model straight into YAML frontmatter, both of which come from unvalidated free-text prompts. A description like `Scout: find things` made the generated file unparseable — and since [#212](https://github.com/tintinweb/pi-subagents/issues/212) an unparseable agent file is *skipped* with a warning, so the wizard reported `Created <path>` for an agent that then silently did not exist — and, under `strictAgentFiles`, aborted the next startup too, that setting gating startup only. A description containing a `#` was quietly truncated at the `#`, YAML treating the rest as a comment. Both values are now quoted, the same way the eject serializer already quoted its description. A `provider/model:thinking` suffix is unaffected either way — YAML only splits on a colon *followed by a space* — and is pinned so the quoting cannot mangle it.
|
||||
- **`/agents → Scheduled jobs` cancelled the wrong job when two names looked alike**. The menu formats each job into a row, then resolves the user's pick by matching the returned string back against the row list. Rows truncate the job name to 18 characters for column alignment, and job names come from the `Agent` call's `description`, which is LLM-authored and routinely shares a prefix — so two jobs like `review the auth module A` and `review the auth module B`, on the same schedule and agent type, produced byte-identical rows and the match always resolved to the first. Picking the second cancelled the first, and because the confirmation dialog was built from the same wrong job, its details looked correct and the user had no way to notice before confirming. Rows are now numbered and carried alongside the job they describe rather than in a parallel array, so labels are unique by construction and no data-dependent collision path exists. The same string-matching pattern in `/agents → Running agents` — where the consequence was only opening the wrong agent's read-only viewer — now goes through the same helper. Pi's dialog API returns the selected string with no index or value form, so uniqueness at the call site is the only available fix. The whole menu had no test coverage at all, which is how a destructive action shipped with this in it.
|
||||
- **The agent widget hid queued agents without counting them**. Past its 12-line cap the widget hands out a line budget — running agents first, then the one-line `N queued` summary, then finished agents — and increments a hidden-count for running and finished rows that don't fit. The queued summary was dropped with no counter at all, so with five running agents, one queued and one finished the widget rendered `+1 more (1 finished)`: two things hidden, one reported, and no indication anywhere that work was waiting to start. That is precisely the state the queue matters in, since it only appears once `maxConcurrent` is saturated. The queued line's row is now reserved before the running agents are laid out, so it can no longer be dropped; the trade is that one running agent may yield its two rows, and that agent *is* counted in the overflow total. Counting the queued line instead was rejected: it summarizes N agents, so counting it as one under-reports and counting it as N inflates a total denominated in agent rows.
|
||||
- **`/agents` Enable/Disable now agree with the loader about `enabled: false`** (found while closing test-suite gaps). Loading parses frontmatter with a real YAML parser; the menu edited it with regex, and the two disagreed. Enable's pattern was anchored to byte 0, so it only matched when `enabled: false` was the *first* frontmatter line — the shape this extension writes, not the shape README tells users to hand-author, where `description:` comes first — leaving a hand-written agent disabled forever while reporting `Enabled <name>`. Disable's idempotence check was byte-exact, so a trailing space defeated it and inserted a *second* `enabled: false`; duplicate keys are a YAML error, and since [#212](https://github.com/tintinweb/pi-subagents/issues/212) an unparseable agent file is skipped, so the agent vanished from `/agents` instead of showing as disabled — and would abort the next startup under `strictAgentFiles`, which gates startup only, every mid-session reload skipping whatever it is set to. Deciding whether a file is disabled now *calls* the parser rather than mirroring it — which was always a read — fixing both the spellings YAML also accepts (`False`, `FALSE`, a trailing `# comment`, a quoted key) and pi's fence scan, which ends the block at any line merely starting `---`: below a `----` line the regex read body text as frontmatter and refused to disable a *running* agent. Editing stays line-wise by design, since re-serializing a parsed document would strip the comments, key order, and quoting of a file users hand-author. That leaves removal recognizing only a lowercase bare `false`; the other spellings are detected but not yet rewritable, and now say so instead of claiming success, with `/agents → Edit` as the way through.
|
||||
- **The `Agent` tool description no longer tells the orchestrator that a zero-tool agent has every built-in**. `tools: none` and a `tools:` listing only `ext:` selectors both parse to an empty built-in list, and the runtime honors that — the shipped e2e fixture pins that such an agent really is handed no `read`, `bash`, `edit`, `write`, `grep`, `find`, or `ls`. The description builder collapsed "empty list" into the same `(Tools: *)` it uses for an *absent* `tools:`, which genuinely does mean all built-ins, so the only capability statement the orchestrator sees before spawning contradicted what the agent would actually get, and work was routed to agents that could only fail at it. An omitted `tools:` still renders `*`. An empty one renders `none` only when the agent can genuinely call nothing — `isolated`, or `extensions: false` — because zero built-ins is not zero tools: `tools: none` alongside `extensions:` still surfaces every extension tool, and the shipped fixture for that case expects three of them. That configuration now renders `no built-ins, extension tools only`, since describing it as `none` would understate the agent and route work away from the only one able to do it — the same failure as the original bug, pointed the other way. The suffix has only ever described *built-in* scope and still does: extension tools are resolved when the agent runs (extensions may register asynchronously) and cannot be enumerated while the description is built, which is why `tools: "*, ext:mcp/search"` renders `*` and always has. The same absent-vs-empty conflation is corrected in the eject serializer, which wrote `tools: all` for an empty list; that path is not reachable from the menu today, since eject is offered only for built-in defaults and none ships with an empty tool list, so it is hardening rather than a fix.
|
||||
|
||||
## [0.15.1] - 2026-08-13
|
||||
|
||||
### Added
|
||||
- **`strictAgentFiles` — fail startup on a broken agent file instead of skipping it**. Off by default. When on, an unreadable or unparseable `agents/*.md` aborts extension load and names the file, so a checked-in `.pi/agents/` can't silently fall through to a different agent. Startup only: mid-session reloads (one per `Agent` call) keep warning, since a bad edit shouldn't kill the session on an unrelated spawn. Settable in `subagents.json` or via `/agents → Settings → Strict agent files`.
|
||||
|
||||
### Fixed
|
||||
- **A `persist_session` subagent's session now records the session that spawned it** ([#205](https://github.com/tintinweb/pi-subagents/pull/205) — thanks [@0xbentang](https://github.com/0xbentang)). `SessionManager.create(...)` was called without pi's `NewSessionOptions`, so a persisted subagent wrote a session header with no `parentSession` — indistinguishable from one started by hand. pi's session picker builds its tree from exactly that field, so every persisted subagent appeared as its own top-level root: a plan → review → implement orchestration left a flat run of sessions with nothing tying them to the session that produced them, and no way to tell them apart but their first message. The spawning session's file path is now passed through, so those sessions nest under their parent in the `/resume` picker. Grouping still needs both sessions in the same listing — pi lists per session directory, derived from the cwd unless one is named — so a `session_dir` override, or an `isolation: worktree` agent running in the copy's cwd, lands in a different directory and keeps showing as a root; the header link is written either way and travels with the file. A spawning session with no file behind it (any in-memory session — the default for a subagent) leaves the field unset, as before.
|
||||
- **A subagent that never starts now fails the tool call instead of reporting success** ([#179](https://github.com/tintinweb/pi-subagents/issues/179) — thanks [@xz-dev](https://github.com/xz-dev)). A strict `isolation: "worktree"` spawn in a directory that is not a git repo, has no commits, or where `git worktree add` fails throws before any child session exists — and the `Agent` tool caught that throw and returned the diagnostic as ordinary result text. Pi marks a tool result failed only when `execute` throws; an `isError` flag on a *returned* result is discarded. The parent model therefore saw `isError: false` and read the message as a subagent that had run and reported this, then retried the same doomed call. Both spawn paths now let the throw out. Nothing else changes: a subagent whose *run* fails, or which is aborted or stopped, still settles on its record and keeps its existing result text and status note — the only calls that now fail are the ones where no agent was ever created. The foreground spinner and widget cleanup moved into a `finally` so they cannot be skipped by an early return.
|
||||
- **An `Agent` call refused before it ran no longer reports a turn limit that never happened** ([#199](https://github.com/tintinweb/pi-subagents/issues/199) — thanks [@Tonours](https://github.com/Tonours), fixed in [#195](https://github.com/tintinweb/pi-subagents/pull/195) by [@xz-dev](https://github.com/xz-dev)). Pi reports every pre-execution failure — an extension `tool_call` hook returning `{ block: true }`, an abort, a schema violation — as `{ content: [reason], details: {} }` with `isError` set. The result renderer's guard tested only for *absent* `details`, and `{}` is truthy, so those results walked past every status branch into the tail and printed `✗ Aborted (max turns exceeded)`. The operator debugged a phantom turn limit while the real reason went only to the model, which alone received the tool result text. A result carrying no status now renders that reason verbatim, and the turn-limit wording is reached only by a genuinely `aborted` run instead of serving as the catch-all for anything unrecognised.
|
||||
- **`isolation: "worktree"` agents are told to stay in the worktree** ([#187](https://github.com/tintinweb/pi-subagents/issues/187) — thanks [@cad0p](https://github.com/cad0p)). The agent was told its working directory twice — the copy, and the main checkout named by the inherited parent prompt or by the task prompt — and followed the latter, editing and committing in the shared tree. Worktree spawns now carry a `<worktree_isolation>` block naming the copy as theirs and the parent as off-limits. A prompt directive, not enforcement: local trials went from 5/5 escapes to 1/7, so `isolation` is still not a hard sandbox.
|
||||
- **One malformed agent file no longer takes pi down with it** ([#212](https://github.com/tintinweb/pi-subagents/issues/212) — thanks [@daromaj](https://github.com/daromaj)). A YAML error in any discovered `agents/*.md` aborted extension activation: pi exited 1 before the TUI, naming no file. Unreadable and unparseable files are now skipped, with a warning naming the file and the error. If the skipped file was overriding a same-named agent, a second line names the file that loads in its place.
|
||||
|
||||
## [0.15.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
- **`fallbackSubagent` — choose what happens when `subagent_type` doesn't resolve, including failing closed** ([#183](https://github.com/tintinweb/pi-subagents/issues/183) — thanks [@0xbentang](https://github.com/0xbentang)). Dispatch previously repaired every unresolvable type by substituting `general-purpose`, so a typo silently ran a different agent, model and tool policy — and for a background or scheduled call, execution began before the caller saw any indication. The new project setting names the substitute instead of assuming one: omitted keeps today's `general-purpose` behavior, any enabled agent name routes unresolvable calls there, and `none` (or the boolean `false`, which would otherwise be dropped as the wrong type and silently leave the fallback in place) disables it entirely so dispatch fails closed with an error listing the available types. Resolution now happens at one point shared by every caller-supplied spawn — the `Agent` tool, the scheduler at fire time, cross-extension RPC, and nested delegation — so a refused type never reaches `runAgent`, where the old three-tier fallback in `getConfig` would have substituted an agent anyway. Case-ambiguous names (two enabled agents differing only in case, reachable because agents are keyed by filename across three directories) are treated as unresolvable rather than resolved to whichever was registered first. The fallback note is emitted at resolution rather than after the run, so the background and schedule paths carry it too, and a scheduled job now stores the type that was actually requested instead of the substitute. Nested delegation keeps rejecting unconditionally whatever this is set to, so a project-level fallback cannot hand a nested caller an agent outside its allowlist. An explicitly configured fallback that is itself unknown or disabled is reported as the misconfiguration it is.
|
||||
- **Opt-in nested subagent delegation — a subagent can delegate to child agents of its own** ([#164](https://github.com/tintinweb/pi-subagents/pull/164) — thanks [@codesoda](https://github.com/codesoda)). Default-off and per-agent: a custom agent receives scoped `Agent`, `get_subagent_result`, and `steer_subagent` tools only when its frontmatter sets `allowed_subagents` (a comma-separated type list, or `all` — `true` and `"*"` are accepted the way `extensions:`/`skills:` take booleans), and never when it is `isolated`. The allowlist is a privilege boundary rather than a routing hint: a child runs with its own `tools:`, `extensions:`, and `isolated:`, so delegating grants the parent the union of what the listed agents can do — `all` reaches `general-purpose`. Nesting is depth-capped from the main session — main is 0, its subagents 1, their children 2 — through `maxSubagentDepth` in `subagents.json` (default `2`; `0` or `1` disables nesting project-wide) or `/agents → Settings → Nested depth`. A per-agent cap is deliberately not exposed yet: at the default project cap it could only restate what omitting `allowed_subagents` already says, and a tighten-only field is safe to add once someone raises that cap. An agent already at the cap is given no nested tools at all, rather than tools whose every call would fail. Everything a parent can do is ownership-scoped — it can fetch, resume, and steer only its own children — and unknown, disabled, or out-of-list types are refused rather than falling back to general-purpose. Nested spawns resolve agents from a registry built for their own config root (never the process-global registry the main session uses) and revalidate `model:` against Model Scope exactly like a top-level spawn. Nested records stay internal to their parent, absent from top-level tools, lifecycle events, and the agent UI, and are stopped when that parent finishes, is stopped, or ends a resumed turn — a hidden child must not outlive the only agent able to reach it. They still write their own `.output` transcript under the root session's directory (same `output_transcript` gate), so a nested run remains inspectable, and their token usage is folded into every ancestor's totals so nested spend stays attributable at any depth, and a nested result that ends `stopped`, `aborted`, or `steered` is labelled partial, the same guarantee top-level results carry. Nested children do not occupy `maxConcurrent` slots: their parent already holds one, and queueing them behind it would deadlock a parent waiting on its own child; the depth cap bounds how deep nesting goes, not how wide, so a `max_turns` on the delegating agent is what ceilings its fan-out. A subagent session no longer activates this extension at all — that is what keeps a child from building a second agent manager, and it is why the nested tools are injected directly instead; a subagent consequently also gets no `/agents` command, no cross-extension RPC handlers, and no `subagents:ready` event.
|
||||
|
||||
### Fixed
|
||||
- **A disabled agent is no longer dispatchable, and no longer runs with a mixed identity** (found while implementing [#183](https://github.com/tintinweb/pi-subagents/issues/183)). The `Agent` tool resolved types with `resolveType()`, which reports a canonical name without checking `enabled` — so an agent disabled via `enabled: false` or `/agents → Disable` resolved successfully, produced no fallback note, and then split downstream: `getAgentConfig` returned the disabled agent's config to build the system prompt while `getConfig` returned general-purpose for tools, extensions, skills and prompt mode. The run therefore carried the disabled agent's instructions with a different agent's tool policy. Dispatch now requires the type to resolve to exactly one *enabled* agent, so a disabled type takes the fallback path — or is refused outright under `fallbackSubagent: none`.
|
||||
- **A foreground agent result now says its output is complete, not partial — it is the *task* whose completion is in question** ([#174](https://github.com/tintinweb/pi-subagents/issues/174) — thanks [@killianMei](https://github.com/killianMei)). A foreground run that ended early was annotated `output may be partial`, which reads as *truncated, go retrieve the rest*. It isn't: a foreground result already carries the agent's entire output inline, and its parent has no agent id to retrieve anything with — the id travels in the tool result's renderer `details`, which is never serialized to the model, and only the background path prints `Agent ID:` in text. A parent acting on that reading therefore passed an id it had invented and got back `Agent not found: "<id>". It may have been cleaned up.`, which looks exactly like the record was evicted. It wasn't: foreground completion mutates the record in place, and nothing removes it outside the 10-minute cleanup timer (which keys on `completedAt`, so a long run doesn't age it), `clearCompleted()` on a session start/switch, or shutdown — none of which the report hit. Foreground results now carry their own outcome note stating that everything the agent produced is above and that it is the *task* whose completion is in question — hedged for a `steered` run, which was told to wrap up and did, so it may well have finished at the limit, and stated outright for `aborted` and `stopped`, neither of which ever delivered a final answer; the background notification and `get_subagent_result` keep the existing note, because their caller holds a 500-char preview and a real id, where the same sentence would be false. Every clause is a statement about state rather than an instruction to act: nothing here can measure whether a wording change improves parent behavior, so removing a false cue (which cannot induce new behavior) and adding an instruction (which can) are not equally safe — and an instruction like "ask before restarting" would also presume a user is present to ask, which is false under `pi -p`, in scheduled jobs, and in background-driven runs. Regression tests pin the record-retention contract, the absence of an id in foreground result text, and that a subagent session's own activation lifecycle can't evict the parent's records.
|
||||
|
||||
## [0.14.3] - 2026-07-23
|
||||
|
||||
### Fixed
|
||||
- **Subagents can use extension tools that register asynchronously, e.g. over MCP** ([#141](https://github.com/tintinweb/pi-subagents/pull/141) — thanks [@philipmw](https://github.com/philipmw)). MCP-backed extensions usually can't enumerate their tools until their servers connect, so they call `registerTool` from `session_start` (pi-mcp) or `before_agent_start` (context-mode) rather than at load — eagerly connecting during extension discovery would orphan child processes on pi's non-agent code paths (`--help`, config, trust probing). The agent runner, however, snapshotted `extension.tools` into a static `tools:` allowlist immediately after `loader.reload()` and *before* `bindExtensions()` fired `session_start`. Because pi's `allowedToolNames` gates tool **registration** — not merely the initial active set — a name missing from that snapshot was dropped permanently, so those tools never reached a subagent even though the main agent had them. The runner now leaves the allowlist unset so pi's live gate admits tools whenever they register, and expresses the name-stable part of the scope (this extension's own orchestration tools, built-ins the agent didn't ask for, `disallowed_tools:`) as a denylist that pi re-applies on every registry refresh. `extensions: false` / `isolated: true` keep the static allowlist unchanged — nothing can register asynchronously there, and a hard registry gate is the right boundary.
|
||||
- **`ext:` selectors keep narrowing correctly when tools register late** (fixes [#125](https://github.com/tintinweb/pi-subagents/issues/125)). Admitting late-registered tools is only half the problem: an `ext:` selector is an *allowlist*, and a not-yet-registered tool cannot be listed in one — which is why `tools: "*, ext:context-mode/ctx_execute"`, the exact configuration reported in #125, still saw nothing. `ext:` narrowing is now enforced on the **active** tool set (what the model actually sees) rather than the registry, re-derived from the loader's live extension maps — the same maps `registerTool` writes into — so a tool is judged against the selectors whenever it appears. Scope is re-applied on every `turn_end`, and the hooks live on the session rather than the spawning call, so steered and resumed turns stay scoped too. Turn 1 is guarded at call time instead: `before_agent_start` fires *inside* `prompt()` and can widen the tool set after that turn's tools are already snapshotted, leaving no window to narrow in, so an out-of-scope call there is refused rather than executed. Selecting a lazy extension (`ext:mcp`) now surfaces its tools; leaving one out still mutes it, no matter when it registers. Vetoing a call means wrapping the `beforeToolCall` hook pi installs on the session (pi exposes the veto to *extensions* as `pi.on("tool_call")`, but there is no equivalent for an SDK caller constructing a child session); the wrapper chains to pi's own hook so extension `tool_call` handlers still fire, and a `pi@latest` CI job guards that the hook stays reachable, so a future pi that moves it surfaces as a test failure rather than a silently missing veto.
|
||||
- **FleetView selection markers now match the rest of the UI** ([#155](https://github.com/tintinweb/pi-subagents/pull/155) — thanks [@xz-dev](https://github.com/xz-dev), closes [#122](https://github.com/tintinweb/pi-subagents/issues/122)). FleetView was the only view using `⏺` (U+23FA) / `◯` (U+25EF) for its selected/unselected rows; everywhere else — the conversation viewer and agent widget — uses `●` / `○` (U+25CF/U+25CB), the base geometric circles with the broadest terminal-font coverage (`◯` renders visibly oversized in many fonts). Selection is already carried by the accent-vs-dim color, so the exotic glyphs added nothing. FleetView now uses `●` selected / `○` unselected, converging on the codebase's existing pair.
|
||||
- **`get_subagent_result(wait: true)` is now cancellable** ([#159](https://github.com/tintinweb/pi-subagents/pull/159) — thanks [@exoulster](https://github.com/exoulster), fixes [#158](https://github.com/tintinweb/pi-subagents/issues/158)). The tool received the tool-call `AbortSignal` but ignored it, so pressing `Esc` during a wait couldn't cancel it — the call stayed blocked until the child finished, and pi could log a spurious `No running process to background.`. Cancelling now stops **only** the wait: the background agent keeps running, its result stays unconsumed, and its normal completion notification still arrives. Queued waits are abortable the same way. Because the wait no longer pre-marks the result consumed (a cancelled wait never consumes it), a successful wait still suppresses its own redundant notification by cancelling the held nudge on completion — the queued-wait poll interval was tightened so that cancellation reliably lands inside the notification hold window.
|
||||
- **User-scope agent memory honors `PI_CODING_AGENT_DIR`** ([#166](https://github.com/tintinweb/pi-subagents/pull/166) — thanks [@biowaffeln](https://github.com/biowaffeln)). The `user` memory scope hardcoded `~/.pi/agent-memory/<name>`, ignoring `PI_CODING_AGENT_DIR` even though every other config consumer (custom agents, enabled models, settings, skills) already routes through `getAgentDir()` — so anyone who relocated their config dir got a stray `~/.pi` resurrected the first time a `memory: user` agent ran. User-scope memory now resolves to `<agentDir>/agent-memory/<name>` (default `~/.pi/agent/agent-memory/`). Existing memories aren't orphaned: when the legacy `~/.pi/agent-memory/<name>` directory exists and the new location doesn't, it keeps being used — symlinked legacy dirs are ignored, consistent with the file's other symlink defenses — and once the new location exists it wins. Lazy fallback rather than auto-migration, so no files are moved.
|
||||
|
||||
## [0.14.2] - 2026-07-17
|
||||
|
||||
### Added
|
||||
- **`output_transcript` frontmatter + `outputTranscript` project setting — opt out of a subagent's `.output` transcript** ([#146](https://github.com/tintinweb/pi-subagents/pull/146) — thanks [@Thoughts-One](https://github.com/Thoughts-One)). Every subagent streams its full conversation to a per-subagent JSON-lines transcript under the OS temp dir (`<tmpdir>/pi-subagents-<uid>/…/<agent-id>.output`, owner-only `0700`, cleared on reboot); until now that write was unconditional. Set `output_transcript: false` on a custom agent to write no transcript file or path for it, or `outputTranscript: false` in `subagents.json` to make transcripts opt-in for the whole project (a custom agent's frontmatter overrides the project default). Useful when run transcripts shouldn't sit on disk for backup or DLP tooling to ingest. Scope is deliberately narrow — it governs only the `.output` transcript, not the persisted pi session (`persist_session`), worktree commits (`isolation: worktree`), or memory files — so keeping a run fully off disk means setting those too. Default is unchanged: with neither flag set, transcripts are written exactly as before. The write decision is centralized on `record.outputFile`, so every downstream consumer (streaming, notifications, the transcript footer) keys off a single gate.
|
||||
|
||||
### Fixed
|
||||
- **Bordered conversation-viewer rows stay exact-width at double-width truncation boundaries** ([#153](https://github.com/tintinweb/pi-subagents/pull/153) — thanks [@xz-dev](https://github.com/xz-dev)). The conversation viewer pre-pads each row to the inner width and truncates it to fit between the `│` borders, but `truncateToWidth` was called without its `pad` flag — so when a truncation boundary fell mid-way through a double-width character (CJK, wide emoji), the result came back one column short and the right border shifted left by a column on that row. The row builder now truncates with padding on, restoring the trailing column, so every bordered row renders at exactly the box width regardless of where a wide glyph lands. A regression test sweeps a double-width character across every truncation boundary and asserts each rendered line is exactly the requested width.
|
||||
- **Isolated subagents keep extension-registered custom providers on pi 0.80.8+** (fixes [#151](https://github.com/tintinweb/pi-subagents/issues/151) via [#152](https://github.com/tintinweb/pi-subagents/pull/152) — thanks [@0xbentang](https://github.com/0xbentang)). pi 0.80.8 replaced `createAgentSession`'s `modelRegistry` option with `modelRuntime`, and the agent runner still passed the now-ignored `modelRegistry` — so pi built a *fresh* model runtime from disk for the child session. Because isolation also disables extension loading, that fresh runtime had neither the extension-registered custom provider nor its auth: an `isolated: true` agent pinned to such a provider failed preflight with `No API key found for <provider>`, while the same agent with `isolated: false` — or on any pi <0.80.8 — worked. The runner now forwards the parent session's `ModelRuntime` (read off the `ModelRegistry` facade on `ctx.modelRegistry`) as `modelRuntime` when the running pi exposes one, and still passes `modelRegistry` for the pre-0.80.8 range — so the fix spans the whole `>=0.80.0` peer range without changing it: older pi omits the new field and takes the legacy path, 0.80.8+ inherits the parent runtime. Extensions and tools stay isolated exactly as before — only the model providers and their auth are inherited. A `pi@latest` CI job guards the reachability of that runtime accessor (a `private` field the fix reaches through), so a future pi that renames or hides it surfaces as a test failure instead of a silent return of this bug.
|
||||
|
||||
## [0.14.1] - 2026-07-14
|
||||
|
||||
### Added
|
||||
- **`max` thinking level is now advertised in the frontmatter/tool/wizard choices** ([#147](https://github.com/tintinweb/pi-subagents/issues/147) — thanks [@justin-ramirez-gametime](https://github.com/justin-ramirez-gametime)). pi 0.80 added `max` to its `ThinkingLevel`, and the extension already forwarded the value unchanged, but the Agent tool description, generated-agent template, `/agents` creation wizard, and README all stopped at `xhigh` — hiding a valid capability. Those four surfaces now come from one shared list so they can't drift behind pi again. Actual availability still depends on the host pi version and the selected model; pi clamps unsupported levels down.
|
||||
|
||||
### Fixed
|
||||
- **Runs whose final assistant turn failed are now reported as `error`, not `completed` with an empty result** (fixes [#144](https://github.com/tintinweb/pi-subagents/issues/144) — thanks [@possibilities](https://github.com/possibilities) for the diagnosis). pi resolves an exhausted-retries provider failure normally — the final assistant message carries `stopReason: "error"` plus an `errorMessage`, no rejection — so the manager mapped such runs to `completed` and every consumer (foreground tool result, `get_subagent_result`, background notifications, resume, scheduler, RPC events) saw a clean success reading `No output.` — or worse, an *earlier* turn's text presented as the fresh answer, since the history fallback walks past empty messages. The runner now inspects the final assistant message and reports a failure in two cases: the turn stopped with `stopReason "error"`, or it hit the output-token ceiling (`stopReason "length"`) having produced no text at all — a silent max-token death that reproduced the same empty-`No output.` symptom. Status still derives from how the final turn stopped, never from whether earlier turns produced text: empty-but-clean finals (tool-call-only or thinking-only endings) stay `completed`, a `length` stop that *did* produce text is a legitimate truncated answer and stays `completed`, partial-text provider errors are still failures, and the walk-back fallback keeps preserving partial output for aborted/steered runs. (An `aborted` final turn needs no special-casing here — the manager's hard-abort flag and `stopped` guard already surface it as `aborted`/`stopped`.) The response-text collector also no longer resets on user/tool-result `message_start` events (it tracked the last *message*, not the last *assistant* message). A failed run still surfaces any output it *did* produce: the tool result shows `Agent failed: <error>` followed by that text under a `Partial output before the failure:` label — and the history fallback is now bounded to the current invocation, so a failed **resume** no longer returns the *previous* turn's answer as this run's result (it returns empty). **Behavior change:** runs that previously ended `completed` with an empty result now end `error` carrying the provider message, and `subagents:failed` fires where `subagents:completed` did; scheduled jobs record `lastStatus "error"` for them.
|
||||
- **A subagent can allowlist a package-installed extension by its package name, not just its source directory** (fixes [#143](https://github.com/tintinweb/pi-subagents/issues/143) — thanks [@possibilities](https://github.com/possibilities)). Our extension-scoping (`extensions:` / `exclude_extensions:` / `tools: ext:…`) named an extension from its file path, and for an `index.ts` entry it used the parent directory — so a package installed via `pi.extensions: ["./src/index.ts"]` (like this one) only ever matched as `src`, an unstable, collision-prone name that no user would guess; `extensions: [pi-subagents]` silently matched nothing. An extension now also answers to its package's unscoped short name (`@tintinweb/pi-subagents` → `pi-subagents`), read from the nearest `package.json` whose `pi.extensions` manifest actually declares that entry. This is an **added alias** — the path-derived name keeps working, so nothing that already matched `src` breaks — and it fires only for genuinely manifest-declared package entries, so a loose extension is never misattributed to a co-located project's name.
|
||||
- **A pi-subagents activation that a child session filtered out no longer advertises or answers cross-extension RPC** (fixes [#142](https://github.com/tintinweb/pi-subagents/issues/142) — thanks [@possibilities](https://github.com/possibilities)). pi runs every extension factory *before* applying an agent's `extensions:` filter and only delivers lifecycle events (`session_start`, …) to the survivors, but the `pi.events` bus is shared with the filtered-out activations too. Because we registered the RPC handlers and emitted `subagents:ready` at factory time, a child agent whose `extensions:` omitted pi-subagents still saw a `subagents:ready` broadcast and a successful `subagents:rpc:ping`, yet every `subagents:rpc:spawn` failed with `No active session` — its `session_start` never fired, so the spawn handler had no context. The RPC handler registration and the `subagents:ready` broadcast now happen on the first bound `session_start` instead of at factory time, so a session that excludes pi-subagents stays completely silent on the RPC channels — behaving like a session where it was never installed, rather than advertising a spawn service it can't provide. Emitting readiness after all factories have loaded also closes a latent race where a consumer whose factory ran after ours could miss the event. No change for sessions that do load pi-subagents: `subagents:ready` still fires and RPC still works, just at `session_start`.
|
||||
|
||||
## [0.14.0] - 2026-07-13
|
||||
|
||||
### Added
|
||||
- **Project custom agents are also discovered from `.agents/agents/<name>.md`** ([#133](https://github.com/tintinweb/pi-subagents/pull/133) — thanks [@wenerme](https://github.com/wenerme); closes [#132](https://github.com/tintinweb/pi-subagents/issues/132)). Projects that keep their agent assets in the shared cross-tool `.agents` workspace (the same convention this extension already reads for `.agents/skills/`) can now define subagents there instead of duplicating files into `.pi/agents/`. Discovery precedence is `global < .agents/agents < .pi/agents`: on a name clash between the two project locations, **`.pi/agents/` wins** — `.pi` remains the project authority, and the `/agents` create/eject/disable flows keep writing there; `.agents/agents/` is a read location only.
|
||||
|
||||
### Changed
|
||||
- **BREAKING: Dev/test toolchain now tracks pi 0.80.x; pi peer floor raised to `>=0.80.0`** (as diagnosed by [@philipmw](https://github.com/philipmw) in [#129](https://github.com/tintinweb/pi-subagents/pull/129)). The committed lockfile had pinned the `@earendil-works/pi-*` peers to 0.75.5 (the latest published when it was generated), so tests and typecheck exercised an older API surface than the pi that actually runs the extension — including 22 tests that silently never ran on 0.75.5 and now do (suite skip count 26 → 4 between the two lockfiles). The lockfile now resolves the peers to 0.80.6, and the test suite imports pi-ai's relocated faux/model helpers (`registerFauxProvider`, `getModel` — `/compat`-only since pi-ai 0.80) through a single re-export module, `test/helpers/pi-ai.ts`, so the next relocation touches one file. Because the test surface no longer resolves on ≤0.75.x, `peerDependencies` move from `>=0.74.0` to `>=0.80.0` to match what is actually tested. Migration: installs under pi <0.80 may emit unmet-peer warnings (or fail under strict peer resolution such as `npm --strict-peer-deps` / pnpm defaults) — upgrade pi to ≥0.80; runtime behavior itself is unchanged on any pi version, since pi substitutes its own bundled modules for extension imports at load time. Live-mode e2e (`PI_E2E_LIVE=1`) now fails fast with a clear error when the pinned `PI_PROVIDER`/`PI_MODEL` isn't in pi-ai's builtin catalog, instead of silently letting the session resolve a different model.
|
||||
|
||||
### Fixed
|
||||
- **Output-file streaming survives session compaction** (fixes [#145](https://github.com/tintinweb/pi-subagents/issues/145) — thanks [@possibilities](https://github.com/possibilities) for the diagnosis). Compaction replaces `session.messages` with a shorter, summarized array, which stranded the streamer's write index past the new end — the flush loop never matched again and the agent's output file silently froze for the rest of the run, exactly on the long background runs that auto-compact. The streamer now flushes any not-yet-written tail when compaction starts and re-anchors its index to the rebuilt array after a successful compaction — deferred one microtask, because on the overflow-retry path pi trims the trailing error assistant message *after* emitting `compaction_end`, and a synchronous anchor would skip the first post-compaction message. Aborted and failed compactions leave the session untouched and change nothing. Verified against a real pi `AgentSession` driving a real `session.compact()` in the new e2e regression.
|
||||
- **FleetView no longer steals arrow/Enter/Esc keys from other interactive components** (fixes [#123](https://github.com/tintinweb/pi-subagents/issues/123) — thanks [@TommyC81](https://github.com/TommyC81) for the report). pi delivers terminal input to extension listeners *before* the focused component, and selector/input dialogs (`ctx.ui.select` & co.) swap the prompt editor out while `getEditorText()` still reads the detached — empty — editor. So while subagents were running, FleetView's empty-prompt gate passed and it consumed the navigation keys that belonged to whatever dialog was actually focused: other extensions' pickers (e.g. rpiv-ask-user-question), pi's own menus, and even `/agents → Settings` itself. FleetView now checks that pi's prompt editor is the focused component before touching any key (pi's editor is an `Editor` subclass; every dialog is not), and an in-progress list navigation is dropped the moment something else takes focus. Unknowable focus errs toward the editor, so list activation keeps working; non-`Editor` custom editor components simply flow keys through untouched.
|
||||
- **Widget and viewer lines keep their dim styling after nested color annotations** ([#136](https://github.com/tintinweb/pi-subagents/pull/136) — thanks [@xz-dev](https://github.com/xz-dev)). pi themes close a foreground color with a bare `\x1b[39m`, so the threshold-colored context-fill percent inside `formatSessionTokens` (warning at ≥70%, error at ≥85%) also terminated the surrounding dim style — the closing `)` and any following text on the widget's running-agent line and the conversation-viewer header bled to the terminal's default color. A small `fgPreservingNestedStyles` helper re-opens the outer color after each nested reset (derived from the theme itself, so it stays theme-agnostic and is a no-op under `NO_COLOR`).
|
||||
- **`get_subagent_result` with `wait: true` now waits for queued agents** ([#127](https://github.com/tintinweb/pi-subagents/pull/127) — thanks [@benrhodeland](https://github.com/benrhodeland)). A background agent past the concurrency ceiling sits in the queue with no run promise yet, so the wait path (gated on `status === "running" && record.promise`) skipped it and returned `No output.` immediately — the orchestrator read a queued agent as "finished with nothing". The wait now also covers `queued`: it polls until the queue starts (or stops) the agent, then awaits the run like any running agent. Most visible with parallel background spawns in one message, where spawns beyond `maxConcurrent` (default 4) queue.
|
||||
- **Subagent activations no longer clobber the `Symbol.for("pi-subagents:manager")` registry** ([#128](https://github.com/tintinweb/pi-subagents/pull/128) — thanks [@benrhodeland](https://github.com/benrhodeland)). Child sessions re-activate the extension in the same process (`session.bindExtensions` in the agent runner), and every activation overwrote the global registry slot — pointing cross-package consumers (RPC extensions, headless hosts) at a short-lived child manager whose shutdown could then delete the root session's entry entirely. The first activation now claims the slot, child activations leave it alone, and only the owning activation releases it (identity-checked) on shutdown.
|
||||
|
||||
## [0.13.0] - 2026-06-30
|
||||
|
||||
### Added
|
||||
- **Steer a running agent from the conversation viewer.** The live conversation overlay (FleetView's `Enter`, or `/agents → Running agents`) now lets you redirect an agent without leaving the view: press `Enter` to open an inline composer, type a message, `Enter` to send — `Esc` or an empty submit just returns. The message is delivered through the same path as the `steer_subagent` tool (`AgentManager.steer()` → `session.steer`, or queued onto `pendingSteers` if the session isn't ready yet), so it appears as a user message and redirects the agent after its current tool execution; feedback is the message showing up in the live transcript you're already watching. The affordance is offered only while the agent is still running/queued (mirrors the `x`/stop affordance), and the viewer stays modal — every existing shortcut (`x`/`x` stop, arrows/`j`/`k` scroll, `q` close) is untouched, and `Enter` was previously inert here so nothing is overridden. The idle footer was reorganized to actions-left / navigation-right so the full scroll-key hint (`↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close`) stays fully visible down to 80-column terminals; the `N lines · %` readout returns on the left whenever there's spare width.
|
||||
- **Forgiving model resolution for agent `model:` pins.** `resolveModel` now tolerates cosmetic id variations and falls back across providers, so a qualified or date-pinned config resolves widely instead of silently dropping to the parent model: `.` and `-` are treated as equivalent in version numbers (`claude-haiku-4.5` ≡ `claude-haiku-4-5`); a trailing `-YYYYMMDD` date stamp is optional (`anthropic/claude-haiku-4-5-20251001` matches an undated registry id); and a `provider/modelId` that isn't available under the named provider retries the bare id against every provider (the named provider is still preferred when present). An exact match still wins over a tolerant one, so dated snapshots aren't conflated — the precedence is exact → fuzzy-under-named-provider → same model under any provider → unavailable.
|
||||
- **`/agents → Agent types` shows each agent's full description and what its model resolves to.** The list now renders with `SettingsList` (like the Settings menu) instead of a flat selector: the highlighted agent's full description shows on its own line below the list, so long descriptions no longer wrap and push the agent names out of alignment. The model column shows the configured model, flags it `(unavailable, fallback: inherit)` when it can't be resolved against the registry (it would silently inherit the parent model at runtime), and surfaces the resolved target `(→ provider/id)` when resolution lands on a different provider or version than configured.
|
||||
- **`widgetMode` setting — control what the above-editor widget shows: `all` / `background` / `off`** ([#117](https://github.com/tintinweb/pi-subagents/pull/117) — thanks [@Alan-TheGentleman](https://github.com/Alan-TheGentleman); fixes [#118](https://github.com/tintinweb/pi-subagents/issues/118)). Foreground agents already render inline as the `Agent` tool result, so also listing them in the persistent widget double-rendered the same run (most visible in tmux/zellij). `widgetMode` (via `/agents → Settings → Widget`, or `subagents.json`) selects the widget's contents: `all` shows every agent (the previous behavior), `background` shows background/queued/scheduled/RPC runs but hides foreground, and `off` hides the widget entirely (agents still appear inline and in FleetView). Applied live — toggling refreshes immediately. Filtering keys off a new tri-state `AgentRecord.isBackground` captured at spawn (`true` = background, `false` = foreground, `undefined` = undeclared, e.g. a cross-extension RPC spawn), independent of the UI-only `invocation` snapshot — so scheduler- and RPC-spawned background agents stay visible instead of vanishing; only runs *known* to be foreground are dropped. The running-status line was also refactored from one multiline `Text` into two component rows, so rapid partial updates replace cleanly instead of leaving stale rows behind in terminal multiplexers — identical on-screen output, no more ghost lines.
|
||||
|
||||
### Changed
|
||||
- **The built-in `Explore` agent's model pin is now `anthropic/claude-haiku-4-5`** (was `…-4-5-20251001`). The stale date stamp is dropped to match the `anthropic/claude-sonnet-4-6` / `anthropic/claude-opus-4-6` convention used by the create-agent wizard; combined with the forgiving resolution above, `Explore` now picks up its fast/cheap model across registry variations (dated, undated, dotted, or under a non-`anthropic` provider) instead of silently falling back to the parent model.
|
||||
- **The above-editor widget now hides foreground agents by default** (`widgetMode` defaults to `background`). Foreground runs still render inline as the `Agent` tool result (and in FleetView); set `/agents → Settings → Widget` to `all` to restore the previous show-everything view, or `off` to hide the widget. Existing `subagents.json` files load unchanged (absent → `background`).
|
||||
|
||||
## [0.12.0] - 2026-06-24
|
||||
|
||||
### Added
|
||||
- **FleetView — a Claude Code-style subagent navigator below the editor.** A persistent, navigable list of `main` + every active subagent renders beneath the editor whenever agents are running — **auto-shown, no keypress needed** — mirroring Claude Code's bottom fleet bar: `⏺`/`◯` selection markers, agent type + description, right-aligned `elapsed · ↓ tokens`, and a `↓ N more` overflow once past five rows. Press `↓` (or `←`) at an **empty prompt** to move focus into the list, `↑`/`↓` to select, `Enter` to open the selected agent's live, auto-updating conversation overlay, and `Esc` (or `↑` above `main`) to return to the prompt. Implemented as a `belowEditor` widget with all key handling routed through `onTerminalInput` (which fires before the editor); it only captures arrow keys at an empty prompt — and acts on key-**press** only (kitty-protocol release events are ignored, otherwise each tap moved twice) — so typing, history, and cursor movement are untouched. Rows are ordered **earliest-launched first**; only openable agents (those with a session) are shown, so pending/queued agents appear once they start and `Enter` never dead-ends; **finished agents linger ~4s** before dropping out (their elapsed freezes at completion); and a viewer **stays open through its agent's completion** so the final output remains readable. Selection follows the viewed agent by id, so closing a viewer returns you to the same agent even if the list reordered while it was open. Every rendered line is width-clamped — the narrow-terminal crash/flicker class previously fixed in v0.2.7 and [#7](https://github.com/tintinweb/pi-subagents/issues/7). Toggle via `/agents → Settings → Fleet view` (default on; pure-UI, so no LLM-context cost).
|
||||
|
||||
## [0.11.0] - 2026-06-23
|
||||
|
||||
### Added
|
||||
- **`persist_session` / `session_dir` agent frontmatter — persist a subagent as a real pi session** ([#111](https://github.com/tintinweb/pi-subagents/pull/111) — thanks [@codesoda](https://github.com/codesoda)). `persist_session: true` runs the subagent through `SessionManager.create(...)` instead of `SessionManager.inMemory(...)`, so its full transcript is written to pi's normal session location (`~/.pi/agent/sessions`) — inspectable and resumable after the fact, like a top-level session — rather than living in memory only. Useful for long-running, multi-round orchestrations (plan → review → implement → verify) where each subagent's conversation is worth keeping. `session_dir` optionally overrides where the persisted session is written (absolute, `~`, or agent-cwd-relative path); omitted, persistence follows pi's own precedence — `PI_CODING_AGENT_SESSION_DIR`, then the settings manager's `getSessionDir()`, then pi's default location. Both default off/unset, so existing agents are unchanged: the in-memory path is byte-identical to before, and the sidechain `.output` transcript is still written either way.
|
||||
|
||||
## [0.10.4] - 2026-06-23
|
||||
|
||||
### Fixed
|
||||
- **Background agent records lost before result is read** ([#108](https://github.com/tintinweb/pi-subagents/issues/108) — thanks [@philipmw](https://github.com/philipmw)). On session switch or `/new`/`/resume`, `clearCompleted()` removed completed agent records regardless of whether the LLM had retrieved the result, causing `get_subagent_result` to return "Agent not found" for agents that had finished but hadn't been checked yet. `clearCompleted()` now accepts a `skipUnconsumed` flag; session event handlers pass `true`, so records with `resultConsumed=false` are preserved across session transitions. The 10-minute cleanup timer handles eventual eviction. Note: a full session shutdown (`session_shutdown`) calls `dispose()` which clears all records unconditionally — that path is not affected by this fix.
|
||||
|
||||
### Added
|
||||
- **Foreground agent lifecycle completion and conversation logging** ([#105](https://github.com/tintinweb/pi-subagents/pull/105) — thanks [@benrhodeland](https://github.com/benrhodeland)). Two gaps closed: (1) **`onComplete` now fires for foreground agents**, emitting `subagents:completed` / `subagents:failed` lifecycle events and writing a `subagents:record` entry to the parent JSONL — previously only background agents emitted these, leaving cross-extension observers with an orphaned `subagents:started` event and no matching completion. `resultConsumed` is pre-set so the callback skips notifications (the result is returned inline); no change to the tool's return value. (2) **Foreground agent conversations are now streamed to `.output` files** (same `.pi/output/agent-<id>.jsonl` path as background agents) — inline subagent transcripts were previously permanently lost after `spawnAndWait` returned.
|
||||
|
||||
## [0.10.3] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **`SpawnOptions.cwd` — spawn a subagent in a different working directory** ([#96](https://github.com/tintinweb/pi-subagents/issues/96) — thanks [@madeleineostoja](https://github.com/madeleineostoja)). For RPC/programmatic callers (not exposed on the `Agent` tool — the LLM-visible surface is unchanged). The agent's tools operate in the target directory and the prompt's environment block describes it, but **`.pi` config keeps loading from the parent session's project** (new `RunOptions.configCwd` split): the target's `.pi` extensions never execute, and its agents/skills/settings/memory are not picked up — spawning into an untrusted directory sends a worker there with the parent's toolbox, rather than "opening pi there." Composes with `isolation: "worktree"`: the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd keeps its scoping instead of silently widening to the repo root — new `WorktreeInfo.workPath`), and the resulting `pi-agent-*` branch lands in that repo, with the completion message naming it so the orchestrator merges in the right place. Validation is strict, typed, and early — non-strings, relative paths, nonexistent paths, and files all throw curated errors at `spawn()` (before queueing) and are re-checked at queue drain, surfacing as RPC error envelopes (`null` is treated as unset). On dispose, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo).
|
||||
|
||||
## [0.10.2] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **`exclude_extensions:` agent frontmatter — extension denylist for subagents** ([#94](https://github.com/tintinweb/pi-subagents/issues/94) — thanks [@ramhaidar](https://github.com/ramhaidar)). Applied after the `extensions:` include set; exclude wins, including over `tools: ext:` selectors (an excluded extension never loads, so its `ext:` reference becomes the usual orphan warning). The key use case: `extensions: true` + `exclude_extensions: pi-notify` — all extensions except a noisy one, without hand-maintaining an allowlist. Plain canonical names only (case-insensitive); paths, `*`, and unmatched names fire `extension-error:…` warnings (warn-not-abort, as with `extensions:` mismatches); `extensions: false` + an exclude warns that the exclude has no effect. **Not a sandbox:** excluded extensions' factory code still executes once during loading — exclusion suppresses handler binding and tool registration, not load-time side effects. The negation syntax `extensions: ["*", "!name"]` was deliberately rejected: an unquoted `!name` is a YAML tag and silently mis-parses.
|
||||
- **`toolDescriptionMode` setting — opt-in compact Agent tool description** ([#91](https://github.com/tintinweb/pi-subagents/issues/91) — thanks [@tiberiuichim](https://github.com/tiberiuichim)). The full Claude Code-style description costs ~1,400 tokens with the default agents and grows with each custom agent (the type list embeds full agent descriptions) — significant for small/local models. `toolDescriptionMode: "compact"` (via `/agents → Settings → Tool description` or `subagents.json`) swaps in a ~75% smaller description: one-line type list (first sentence of each agent description), terse usage notes, per-option details left to the parameter descriptions. Default `"full"` is byte-identical to before — the rich description's guardrails are deliberately load-bearing and stay the default. A third mode, `"custom"`, registers a user-authored description from `<cwd>/.pi/agent-tool-description.md` (project) or `<agentDir>/agent-tool-description.md` (global; project wins), with `{{placeholder}}` substitution keeping the dynamic parts live — `{{typeList}}`, `{{compactTypeList}}`, `{{agentDir}}`, `{{scheduleGuideline}}` — so a hand-written description can't drift out of sync with the registered agents (the advertised-vs-spawnable staleness [#92](https://github.com/tintinweb/pi-subagents/issues/92) just fixed). Unknown placeholders are left verbatim with a stderr warning; a missing/empty file falls back to `"full"`. Only the prose is customizable — the parameter schema stays code-owned. A ready-made starting point ships at `examples/agent-tool-description.md`, reproducing the full description exactly (CI-enforced byte-identical, so the example can't go stale). Like `schedulingEnabled`, the mode is read at tool registration — changing it applies on the next pi session. The issue's original ask (move the description to a skill) isn't possible in pi: tools must register their description in the tool schema for the model to call them; skills are lazily-loaded instructions, not tool registrations.
|
||||
|
||||
### Fixed
|
||||
- **Conversation viewer honors custom `tui.select.*` keybindings** ([#99](https://github.com/tintinweb/pi-subagents/issues/99) — thanks [@owenniles](https://github.com/owenniles)). The viewer hardcoded its scroll keys and discarded the `KeybindingsManager` pi injects into `ctx.ui.custom()`, so user bindings (e.g. emacs-style `ctrl+p`/`ctrl+n` on `tui.select.up`/`down`) worked in pi core selectors but not here. Scrolling now resolves through `tui.select.up`/`down`/`pageUp`/`pageDown`; the viewer-specific `k`/`j` and `shift+arrow` aliases still work alongside, and behavior without custom bindings is unchanged (the `tui.select.*` defaults are the previously hardcoded keys).
|
||||
|
||||
## [0.10.1] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **`disableDefaultAgents` setting** ([#92](https://github.com/tintinweb/pi-subagents/issues/92) — thanks [@TommyC81](https://github.com/TommyC81)). When on, the three built-in default agents (general-purpose, Explore, Plan) are skipped at registration — only user-defined `.pi/agents/*.md` agents are advertised and spawnable. User agents are unaffected, including ones overriding a default by name; with no user agents defined, spawning falls back to the hardcoded generic config. Off by default; toggle via `/agents → Settings → Disable defaults` or `disableDefaultAgents` in `subagents.json`. Like `schedulingEnabled`, the Agent tool's type list reflects the change on the next pi session (tool schema is registered at startup).
|
||||
|
||||
### Fixed
|
||||
- **Agents with `enabled: false` are no longer advertised in the Agent tool description** ([#92](https://github.com/tintinweb/pi-subagents/issues/92)). `buildTypeListText` listed every registered agent, including disabled ones that `isValidType` then refused to spawn — the LLM was offered types it could never use. The type list now filters through `getAvailableTypes()`, matching the `subagent_type` parameter description.
|
||||
- **Agent tool type list no longer built from pre-settings state.** The description text was captured into a variable before persisted settings were applied; it's now built at tool-registration time, after `subagents:settings_loaded`.
|
||||
- **Committed work from `isolation: "worktree"` subagents is now preserved** ([#68](https://github.com/tintinweb/pi-subagents/pull/68) — thanks [@rylwin](https://github.com/rylwin)). If an isolated subagent creates its own commit, cleanup previously saw a clean `git status`, treated it as "no changes", and removed the detached worktree — silently discarding the commits. The worktree now records its base SHA at creation, and cleanup creates the expected `pi-agent-*` branch whenever HEAD moved past it, even with a clean tree.
|
||||
- **Automatic commits in isolated worktrees skip local Git hooks** ([#68](https://github.com/tintinweb/pi-subagents/pull/68)). The preservation commit at worktree cleanup now uses `--no-verify`, so a failing local pre-commit hook can't abort it (which previously surfaced as `hasChanges: false` — the agent's work lost).
|
||||
|
||||
## [0.10.0] - 2026-06-01
|
||||
|
||||
> **⚠️ Breaking: `extensions:` and `tools:` in agent frontmatter semantics changed.** The `extensions: [...]` array now selects which extensions *load*, not which tool names surface. Agents that previously used the array form will behave differently — see migration below. The `tools:` field also grew new `ext:` and `*` selector forms; existing `tools:` values without these selectors are unchanged.
|
||||
> - `extensions: [...]` is now an **extension allowlist applied at load time**, not a tool-name substring filter. Each entry is an extension *name*, a *path* (absolute, `~/`-prefixed, or relative-to-cwd), or `"*"`. **Migration:** `extensions: ["mcp"]` previously loaded *every* extension and then surfaced only tools whose names contained `mcp`. To keep all extensions, use `extensions: true` or `extensions: "*"`. To narrow, name the extensions or point at their files. `"*"` composes: `extensions: "*, /abs/path/extra-ext.ts"` is all defaults plus one path-loaded.
|
||||
> - `tools:` now accepts `ext:` selectors and `*`. **Gotcha:** a `tools:` value containing **only** `ext:` entries yields **zero built-in tools** — add `*` (e.g. `tools: "*, ext:foo"`) to keep the built-ins. And **any** `ext:` entry flips extension tools to an explicit allowlist (non-listed extensions stay loaded but expose no tools). A `tools:` with no `ext:` entries is unchanged.
|
||||
> - **`extensions:` is the sole loading authority.** `ext:foo` only narrows tool *exposure* within the already-loaded set; it cannot pull an extension in. `extensions: false` + `tools: "ext:foo"` loads nothing and warns that `ext:foo` is orphaned. To expose one extension's tool from an otherwise-narrow agent, name the extension explicitly: `extensions: [foo]` + `tools: "ext:foo/bar"`.
|
||||
|
||||
> **⚠️ Heads-up — widget glyphs changed (visual only):** turn count now renders as `↻N` (was `⟳N`) and compaction count as `⇊N` (was `↻N`). Fix for [#84](https://github.com/tintinweb/pi-subagents/issues/84) — `⟳` overflowed its cell in common monospace fonts. **No API, behavior, or output-format changes — only the glyphs.** If you grep agent stats lines or pipe widget output through scripts, update your patterns: `⟳` → `↻` (turns), `↻` → `⇊` (compactions).
|
||||
|
||||
### Added
|
||||
- **`tools:` accepts `ext:` extension-tool selectors and a `*` built-in wildcard.** Entries in the `tools:` CSV are now partitioned: plain names are the built-in allowlist (unchanged); `*` expands to all built-ins (symmetric with `extensions: "*"`); `ext:foo` / `ext:foo/bar` select extension tools. **Any `ext:` entry flips extension tools to an explicit allowlist** — only tools named by an `ext:` selector reach the LLM, and extensions not named stay loaded (their `session_start` etc. handlers still fire) but expose no tools. `ext:foo` exposes all of `foo`'s tools; `ext:foo/bar` narrows `foo` to just `bar` (multiple `ext:foo/x` entries union; a bare `ext:foo` alongside `ext:foo/bar` lets narrowing win). `ext:` is **narrowing-only** — it does not load extensions. `extensions:` remains the sole loading authority; an `ext:foo` against an extension that `extensions:` excluded (including `extensions: false`) is orphaned and warns via `onToolActivity` (`extension-error:ext:foo …`). With no `ext:` entry present, extension-tool behaviour is unchanged. `ext:` is name-only (matched by canonical name, so it composes with path-loaded extensions); paths still go in `extensions:`. `isolated: true` ignores `ext:` selectors.
|
||||
- **Stop a running agent from the conversation viewer.** In `/agents → Running agents`, select an agent and press `x` (then `x` again to confirm) to abort it. The two-press guard prevents an accidental kill; the footer shows `x stop` → `x again to STOP`. This works for **background** agents — which a global `Esc` can't unambiguously target — while `Esc` still stops a blocking foreground `Agent` call. Wires the existing `AgentManager.abort(id)` to the viewer (`onStop` callback); the affordance only appears while the agent is `running`/`queued`. Addresses the common "how do I stop a background subagent?" question ([#88](https://github.com/tintinweb/pi-subagents/issues/88)).
|
||||
|
||||
### Changed
|
||||
- **BREAKING: `extensions: [...]` in agent frontmatter is now a loader-level extension allowlist, not a tool-name filter.** Previously a `string[]` value filtered exposed *tool names* by substring (`t.startsWith(e) || t.includes(e)`) while every discovered extension still loaded and ran its handlers. Now each entry selects an *extension*: a bare name keeps the matching default-discovered extension, a path (absolute, `~/`-prefixed, or relative-to-cwd) loads that extension fresh via `additionalExtensionPaths`, and `"*"` keeps all default-discovered extensions. Entries compose — `["*", "/abs/foo.ts"]` is all defaults plus foo, `["mcp", "/abs/foo.ts"]` is just those two. Excluded extensions no longer bind handlers or register tools (their factory still runs once during `reload()`). Directory extensions (`foo/index.ts`) match by the parent directory name. **Extension names match case-insensitively** (`extensions: [Mcp]` resolves the same as `[mcp]`); tool names within `ext:foo/bar` selectors remain case-sensitive (they're matched against pi-mono's registered identifiers). Unmatched names and failed paths warn via `onToolActivity` but do not abort the subagent (see the heads-up above for migration).
|
||||
- **Non-normal subagent outcomes are now stated explicitly in the text delivered to the parent**, so the orchestrator can't mistake a stopped/incomplete agent for a completed one. The foreground `Agent` result, `get_subagent_result`, and the `<task-notification>` summary all append a clear note for `stopped` (user abort) → `(STOPPED BY THE USER before completion — output is partial; the task was NOT finished)`, `aborted` (turn limit) → `(aborted — hit the turn limit before completion; output may be incomplete)`, and `steered` → `(wrapped up at the turn limit — output may be partial)`. `stopped` (human intervention) is kept distinct from `aborted` (turn-budget cutoff); a clean `completed` adds no note. Extracted as `getStatusNote` in `src/status-note.ts`.
|
||||
- **`BUILTIN_TOOL_NAMES` is derived from pi's tool factories** (`createCodingTools` + `createReadOnlyTools`) rather than a hardcoded list, so the built-in set tracks pi-mono automatically. Internal; no behavior change (the resolved set is the same seven names).
|
||||
|
||||
### Fixed
|
||||
- **Turn-count glyph in the agent widget no longer overflows its monospace cell** ([#84](https://github.com/tintinweb/pi-subagents/issues/84) — thanks [@linozen](https://github.com/linozen)). `formatTurns` used `⟳` (U+27F3 CLOCKWISE GAPPED CIRCLE ARROW) from the Miscellaneous Mathematical Symbols-A block, where common monospace fonts (Iosevka Nerd Font Mono, Menlo, SF Mono, JetBrains Mono) draw the glyph visually wider than one cell despite its Neutral East Asian Width — making the next character (the digit) overlap the glyph. Replaced with `↻` (U+21BB CLOCKWISE OPEN CIRCLE ARROW) from the standard Arrows block, which renders cleanly at one cell in those fonts. To avoid colliding with the existing compaction indicator (which previously also used `↻`), the compaction glyph moves to `⇊` (U+21CA DOWNWARDS PAIRED ARROWS) — same Arrows block, also single-cell, visually distinct. Widget vocabulary now reads: `↻5≤30` for turns, `⇊2` for compactions. Pi UI consumers / scripts grepping for the glyph in stats lines must update.
|
||||
- **`tools: none` now actually yields zero built-in tools.** `getToolNamesForType` treated an explicit empty `builtinToolNames` (`[]`, produced by `tools: none`) as "unspecified" and fell back to all 7 built-ins. It now distinguishes an omitted field (`undefined` → all built-ins, for default agents) from an explicit empty list (`[]` → zero), consistent with `getConfig`. Same fix makes `tools:` values containing only `ext:` selectors yield zero built-ins as documented.
|
||||
- **`tools:` typos no longer silently break tool-calling** ([#75](https://github.com/tintinweb/pi-subagents/issues/75)). Two parts: (a) `all` was previously parsed as a literal tool name, producing a one-element allowlist of the non-existent tool `"all"` — the model then returned an empty response or emitted raw XML tool calls, all with `status: completed` and no error. `parseToolsField` now treats `all` (case-insensitive) as an alias for the `*` wildcard, both standalone and inside a CSV. (b) Plain entries in `tools:` are expected to be built-in names (extension tools route through `ext:`), so an unknown name there is unambiguously a typo. `runAgent` now emits a `tools-error:tool "X" requested by agent "Y" is not a known built-in` event via `onToolActivity` for each unrecognized plain entry — same surfacing channel as the existing `extension-error:` warnings.
|
||||
- **Subagents with `extensions: true` now actually expose extension-registered tools (MCP, etc.)** ([#47](https://github.com/tintinweb/pi-subagents/issues/47)). `runAgent` previously passed only the built-in tool names as the `tools:` allowlist to `createAgentSession`, so pi-mono's `allowedToolNames` gate rejected every extension-registered tool at registration — `extensions: true` agents silently got only the 7 built-ins. `runAgent` now enumerates extension tool names from the resource loader after `reload()` and builds the full master allowlist (built-ins + permitted extension tools), so pi-mono's gate admits them from the first instant of the session. `disallowedTools` and the internal `Agent`/`get_subagent_result`/`steer_subagent` exclusions are applied uniformly to built-in and extension tools at construction — no post-construction `setActiveToolsByName` narrowing.
|
||||
- **Append-mode subagents no longer defeat the LLM's KV cache** ([#73](https://github.com/tintinweb/pi-subagents/pull/73) — reported by [@jeffutter](https://github.com/jeffutter)). The assembled child prompt placed the per-spawn-varying `<active_agent>` tag and `# Environment` block *before* the ~8k-token inherited parent prompt, and wrapped the parent prompt in `<inherited_system_prompt>` tags. Because KV caches key on a byte-identical prefix, every subagent spawn reprocessed all ~8k shared tokens from scratch (~40s on slower hardware). The parent prompt is now emitted **verbatim at the start** of the prompt (wrapper dropped), so it forms an identical, cacheable prefix with the parent session and across every spawn; the static `<sub_agent_context>` bridge follows, then the varying `<active_agent>` tag and env block. `replace` mode is unchanged (it inherits no parent prefix). The `<active_agent>` tag stays present and is parsed position-independently, so downstream permission resolution is unaffected. Mirrors the fix in [gotgenes/pi-packages#180](https://github.com/gotgenes/pi-packages/issues/180).
|
||||
|
||||
## [0.9.1] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **`Agent`, `get_subagent_result`, and `steer_subagent` now surface in pi's default system prompt** ([#87](https://github.com/tintinweb/pi-subagents/pull/87) — thanks [@that-yolanda](https://github.com/that-yolanda)). Adds `promptSnippet` to all three (a line in the prompt's `Available tools:` section) and `promptGuidelines` to `Agent` (bullets in `Guidelines:`). The tools were always callable via the tool-call API; this only adds system-prompt reinforcement for prompt-following models. No schema or tool-call changes.
|
||||
|
||||
## [0.9.0] - 2026-05-30
|
||||
|
||||
> **Heads-up — orchestrator behavior may shift.** This release substantially rewrites the `Agent` tool description and the three default-agent descriptions (`general-purpose`, `Explore`, `Plan`) to mirror Claude Code's upstream wording. No API, schema, or tool-call shape changes — purely a prompt-engineering shift, but a load-bearing one:
|
||||
> - **Agent selection may drift.** The new agent descriptions carry richer positive ("Use it to …") and negative ("Do NOT use it for …") guidance plus search-breadth hints for `Explore` (`"quick"` / `"medium"` / `"very thorough"`). For ambiguous tasks where the orchestrator previously picked one default agent, it may now pick another — typically more correctly, but the choice may differ from prior releases.
|
||||
> - **Subagent briefings will skew longer and more contextual.** The restored upstream guardrails and the new `## Writing the prompt` section actively coach "smart colleague who just walked into the room"-style prompts. Expect more context, more constraint, more upfront framing in the `prompt:` field the orchestrator passes to subagents.
|
||||
> - **Parallel/background patterns more strongly enforced.** The merged bullet on parallel execution now explicitly says `run_in_background: true` is required on each tool call for actual concurrency, and that the orchestrator MUST send a single message with multiple tool uses when the user says "in parallel." Workflows relying on sequential-foreground default behavior are unaffected.
|
||||
> - If you have tests or workflows that depend on the prior agent-selection or briefing behavior, pin to a v0.7.x release.
|
||||
|
||||
### Added
|
||||
- **`scopeModels` setting — opt-in subagent model-scope enforcement** (off by default). New setting toggleable via `/agents → Settings → Scope models`. When enabled, the *effective* model of each subagent spawn is validated against `enabledModels` from pi's settings (which pi manages via its own `/scoped-models` UI; pi-subagents only reads it). **Both pi settings files are honored**: global `<agentDir>/settings.json` plus project-local `<cwd>/.pi/settings.json`, with project overriding global — mirrors pi's `SettingsManager` deep-merge and our own `subagents.json` precedence. Out-of-scope handling depends on source: caller-supplied via `Agent({ model: "..." })` → hard error to the orchestrator with the allowed list; frontmatter-pinned or parent-inherited → warning toast + the agent runs anyway (preserves "frontmatter is authoritative" guarantee from v0.5.1; `scopeModels` is a guardrail against runtime LLM choices, not user-level config). Limitation: only exact `provider/modelId` entries in `enabledModels` are honored — globs (`*sonnet*`), bare model IDs, and `:thinking` suffixes that pi itself supports are silently dropped here. Matches pi's `/scoped-models` picker output, so the limitation is invisible to UI users.
|
||||
|
||||
### Changed
|
||||
- **`Agent` tool prompt restructured to mirror Claude Code's upstream Agent tool description format.** Section headings now match upstream (`## When not to use`, `## Usage notes`, `## Writing the prompt`); the auto-generated agent list renders as a flat list (no `Default agents:` / `Custom agents:` sub-headers) with a per-agent `(Tools: …)` suffix derived from each agent's `builtinToolNames` (or `*` when the agent has the full built-in set). Restored upstream's load-bearing guardrails that were missing or compressed in the old prompt: "result is not visible to the user → summarize", "trust but verify", "fresh agent / self-contained prompt" on resume, "tell the agent whether to write code or do research", "use proactively when the description says so", "MUST send a single message for parallel", and the worktree auto-cleanup behavior detail. The three redundant "Use Explore / Plan / general-purpose for …" shorthand bullets were dropped — the agent descriptions themselves now carry the canonical (and richer) selection guidance. Upstream's two `<example>` blocks at the end of "Writing the prompt" are also intentionally omitted: the per-orchestrator-turn token cost is recurring, the abstract guidance + the now-rich agent descriptions cover the same pedagogical ground, and the examples embed Anthropic-specific `<thinking>` framing that doesn't generalize across pi-ai's provider surface (OpenAI, Bedrock, Gemini, Mistral, …). All pi-specific bullets (`resume`, `steer_subagent`, `model`, `thinking`, `inherit_context`, `isolation: "worktree"`, `${scheduleGuideline}`) preserved.
|
||||
- **Default agent descriptions (`general-purpose`, `Explore`, `Plan`) replaced with upstream Claude Code's verbatim wording.** Previously one-line labels (e.g. `"Fast codebase exploration agent (read-only)"`); now multi-sentence descriptions that include positive ("Use it to …") and negative ("Do NOT use it for …") guidance plus, for Explore, search-breadth hints (`"quick"` / `"medium"` / `"very thorough"`). The LLM-facing selection signal is now substantially stronger.
|
||||
- **`/agents → Eject` now emits YAML-safe `description:` frontmatter.** The new Explore description contains a `: ` colon-space pattern (the search-breadth hint) and embedded quote characters — emitting it raw would have produced malformed frontmatter that the `yaml` parser would mis-parse. `ejectAgent` now wraps the description with `JSON.stringify` (a valid YAML 1.2 double-quoted scalar), so any description string round-trips cleanly through eject → re-load. Latent bug: previously unreachable because old descriptions were YAML-plain-safe.
|
||||
- **`/agents → Settings` UI rewritten to inline-editable `SettingsList`.** Replaces the previous modal `ctx.ui.select` chain. All settings visible at once; `↑`/`↓` to navigate, `Space` to cycle preset values on numerics (`Max concurrency`, `Default max turns`, `Grace turns`), `Enter` to type a custom value, `Esc` to exit. Functionally equivalent — same fields, same valid ranges, same persistence behavior — but the interaction model is different. Users scripting against the old screen flow may notice.
|
||||
- **`.gitignore` additions.** Added `.pi/subagents.json` (project-local subagents settings — written by `/agents → Settings`, shouldn't be committed) plus pi-runtime working files (`progress.md`, `AGENTS.md`, `CLAUDE.md`). **Migration:** if you previously committed `.pi/subagents.json` to your repo, run `git rm --cached .pi/subagents.json` to untrack — gitignore only blocks new additions.
|
||||
|
||||
## [0.8.0] - 2026-05-26
|
||||
|
||||
> **⚠️ Breaking: peer dependencies moved from `@mariozechner/pi-*` to `@earendil-works/pi-*`.** The upstream Pi runtime relocated npm scopes on 2026-05-07; the `@mariozechner/pi-*` packages are deprecated. This release pins `@earendil-works/pi-{ai,coding-agent,tui}` at `>=0.74.0`. Hosts on the old scope must update their pi installation first (`pi update --self` handles the rename automatically) before installing this version.
|
||||
>
|
||||
> **Note on Node:** this release is tested against `@earendil-works/pi-coding-agent@latest` (currently `0.75.x`), which requires Node `>=22.19.0` because its bundled `undici` calls Node 22+ APIs. CI runs on Node 22. The peer range (`>=0.74.0`) technically also matches the upstream `legacy-node20` line (`0.74.x`, Node 20 compatible) and this extension contains no Node 22+ API calls of its own, but the legacy line is not exercised in CI — consumers pinning it do so at their own risk.
|
||||
|
||||
### Changed
|
||||
- **Peer deps migrated from `@mariozechner/pi-*` to `@earendil-works/pi-*`** ([#76](https://github.com/tintinweb/pi-subagents/issues/76) — thanks [@SEHANTA](https://github.com/SEHANTA) for the report). On **2026-05-07** the upstream Pi runtime moved npm scopes — `@mariozechner/pi-coding-agent@0.73.1` was the final publish (now deprecated on npm), and `@earendil-works/pi-coding-agent@0.74.0` shipped 30 minutes later from the same monorepo (same author, same code). `peerDependencies` now target `@earendil-works/pi-{ai,coding-agent,tui}` at `>=0.74.0`, and all `src/**` and `test/**` imports are renamed to the new scope — pure rename, no API changes. Consumers pinning the new scope no longer hit the peer-dep conflict warnings reported in [#76](https://github.com/tintinweb/pi-subagents/issues/76).
|
||||
- **`ThinkingLevel` now imported from `@earendil-works/pi-ai` instead of `…/pi-agent-core`.** `src/types.ts` previously reached past the public API into `pi-agent-core` (an internal package), which only resolved because npm flat-hoisted it as a transitive of `pi-coding-agent` — under pnpm or strict-resolver setups the import failed (`TS2307: Cannot find module '@mariozechner/pi-agent-core'`). `pi-ai` re-exports `ThinkingLevel` from its public surface (`export * from "./types.ts"`), so the import goes through the documented entry point and no extra peer dep is needed.
|
||||
|
||||
### Fixed
|
||||
- **`.pi/subagent-schedules/` is no longer created in every working directory.** `ScheduleStore`'s constructor previously ran `mkdirSync` unconditionally, so any session with scheduling enabled left an empty `.pi/subagent-schedules/` dir behind even when nothing was ever scheduled. Directory creation is now lazy — deferred to a new private `ensureDir()` invoked at the top of `withLock`, so the dir (and its `<sessionId>.json`) appear only when a job is actually persisted. Additionally, `update`/`remove` now short-circuit on an unknown id (in-memory `jobs.has(id)` check) before taking the lock, so no-op mutations never touch disk. Read-only use (`list`/`get`/`hasName`) and constructing the store never create the dir. Pre-existing leftover dirs are not cleaned up — remove them manually.
|
||||
|
||||
## [0.7.3] - 2026-05-14
|
||||
|
||||
### Added
|
||||
- **`<active_agent name="…"/>` tag prepended to every child system prompt** ([#73](https://github.com/tintinweb/pi-subagents/pull/73) — thanks [@chris-lasher](https://github.com/chris-lasher)). `buildAgentPrompt` now emits `<active_agent name="${config.name}"/>` as the first line of the assembled prompt in both `replace` and `append` modes, before the env block. Downstream extensions (e.g. permission/policy systems) can parse it from inside the child session to resolve per-agent policy. The tag uses the agent's `config.name` verbatim — no escaping or normalization — and does not couple this extension to any specific downstream consumer; ignoring it is harmless.
|
||||
|
||||
### Changed
|
||||
- **Subagent sessions now get a stable, type-derived name with an id suffix for parallel spawns** ([#51](https://github.com/tintinweb/pi-subagents/pull/51) — thanks [@forcepushdev](https://github.com/forcepushdev)). `runAgent` calls `session.setSessionName(agentConfig?.name ?? type)`, and when the manager assigns an `agentId` (always, in production), the name is suffixed with an 8-char slice — e.g. `Explore#a1b2c3d4` — so concurrent spawns of the same agent type are distinguishable in the overlay instead of all collapsing onto the same bare name. Direct `runAgent` callers without an `agentId` (e.g. tests) get the bare name.
|
||||
|
||||
### Fixed
|
||||
- **Cross-extension spawn RPC now accepts a string `options.model`** ([#59](https://github.com/tintinweb/pi-subagents/pull/59), fixes [#60](https://github.com/tintinweb/pi-subagents/issues/60)). Cross-extension callers (e.g. `@tintinweb/pi-tasks@>=0.4.3`'s `TaskExecute`) naturally forward `model` as a serializable `"provider/modelId"` string. Previously the spawn handler passed strings straight through to `runAgent()`, which expects a `Model` object — the spawned agent then crashed with `No API key found for undefined`. The handler now resolves strings via the same `resolveModel(ctx.modelRegistry)` path the scheduler uses; `Model` objects pass through unchanged. Unresolved strings surface the human-readable `Model not found: "…"` error instead of the auth-lookup crash. Thanks @any-victor.
|
||||
|
||||
## [0.7.2] - 2026-05-12
|
||||
|
||||
> **Heads-up — behavior changes in skill preloading:**
|
||||
> - **`.txt` and extensionless flat skill files are no longer loaded.** Only `<name>.md` flat files and `<name>/SKILL.md` directory skills resolve now. Rename any `<name>.txt` or extensionless skill files to `<name>.md`.
|
||||
|
||||
### Added
|
||||
- **Pi-standard `<name>/SKILL.md` directory layout** is now discovered alongside flat `<name>.md` files. Top-level and nested matches both resolve via BFS — for skill `foo`, the loader checks `<root>/foo/SKILL.md`, then recursively descends looking for `*/.../foo/SKILL.md`. Recursion skips dotfile directories and `node_modules`; a directory that itself contains `SKILL.md` is treated as a single skill (Pi's "skills don't nest" rule).
|
||||
- **Five discovery roots**, checked in precedence order:
|
||||
- `<cwd>/.pi/skills/` (project, Pi)
|
||||
- `<cwd>/.agents/skills/` (project, [Agent Skills spec](https://agentskills.io/integrate-skills))
|
||||
- `$PI_CODING_AGENT_DIR/skills/` — default `~/.pi/agent/skills/` (user, Pi)
|
||||
- `~/.agents/skills/` (user, Agent Skills spec)
|
||||
- `~/.pi/skills/` (legacy global, kept for backward compatibility)
|
||||
- **Symlink rejection broadened** to the new layouts: symlinked skill roots, nested skill directories, and `SKILL.md` files inside otherwise-real directories are all rejected (intentional deviation from Pi, which follows symlinks).
|
||||
- **Deterministic traversal order** — entries are sorted byte-order so collisions resolve identically across filesystems. Pi's iteration order is `readdirSync`-dependent.
|
||||
- **Resolved spawn args are now shown in the dedicated conversation viewer** ([#62](https://github.com/tintinweb/pi-subagents/issues/62)). Open `/subagent` → Running Agents → select an agent: a second header row displays the effective invocation — model override (when different from parent), `thinking: <level>`, `isolated`, `worktree`, `inherit context`, `background`, and `max turns: N`. Tags appear when the resolved value is notable (e.g. `isolated: true`), not just when the caller explicitly set it; `max turns` is the one exception and shows only when explicitly configured. Lets you verify the parent agent honored your spawn instructions without scrolling back through the chat. Snapshot stored on the new `AgentRecord.invocation` field. The same tag set is also surfaced on the `Agent` tool-call result render (which previously showed a narrower subset).
|
||||
- **`Shift+↑` / `Shift+↓` scroll a full page in the conversation viewer** — same behavior as `PgUp` / `PgDn`. Note: some terminal emulators intercept Shift+arrows for text selection or tab switching, in which case `PgUp`/`PgDn` remain available.
|
||||
|
||||
### Changed
|
||||
- **`.txt` and extensionless flat skill files are no longer loaded.** Pi only supports `.md`; we now match. **Migration:** rename any `<name>.txt` / `<name>` skill files to `<name>.md`.
|
||||
- **Conversation viewer no longer fills the full screen.** The overlay is now capped at 70% of terminal height (90% width unchanged), and the viewer's internal viewport mirrors that cap so the footer/scroll indicator can't be clipped.
|
||||
|
||||
## [0.7.1] - 2026-05-07
|
||||
|
||||
> **Heads-up — behavior change:**
|
||||
> - `isolation: "worktree"` now fails loud (returns an error) instead of silently falling back to the main tree. Affects users running pi in a non-git directory or a fresh repo with no commits.
|
||||
|
||||
### Changed
|
||||
- **`isolation: "worktree"` now fails loud instead of silently falling back.** Previously when `createWorktree` returned undefined (not a git repo, no commits yet, or `git worktree add` failed), the agent ran in the main `cwd` with a `[WARNING: ...]` block prepended to its prompt — visible only to the LLM, never surfaced to the caller. Now the failure throws a structured error that propagates back to the `Agent` tool response; no agent record is created. Failed scheduled fires are recorded as `lastStatus: "error"` with the reason in the `subagents:scheduled` error event. Queued background spawns whose worktree creation fails when they dequeue are marked terminal-error and don't block the rest of the queue.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Headless `pi --print` runs no longer hang or crash after background
|
||||
subagents complete.** Cleanup timers no longer keep the process alive, and
|
||||
stale completion notifications are treated as best-effort shutdown side
|
||||
effects.
|
||||
|
||||
## [0.7.0] - 2026-05-04
|
||||
|
||||
> **Heads-up — behavior changes:**
|
||||
> - `subagents:completed`/`failed` event `tokens.total` now excludes `cacheRead` (previously double-counted across turns) — see Fixed [#38].
|
||||
> - Cron `?` is now a wildcard (same as `*`), not "current time value" — affects Quartz-style expressions only.
|
||||
|
||||
### Changed
|
||||
- **`@mariozechner/pi-{ai,coding-agent,tui}` moved to `peerDependencies` (`>=0.70.5`).** Avoids duplicate framework instances when the host loads this extension.
|
||||
- **`@sinclair/typebox` pinned from `latest` to `^0.34.49`** so installs are reproducible.
|
||||
- **`croner` bumped 8 → 10.** Heads-up: in cron strings, `?` now means wildcard (same as `*`) instead of "current time value" — affects Quartz-style expressions only.
|
||||
|
||||
### Added
|
||||
- **Master switch for scheduling** — new `schedulingEnabled` setting (default `true`) under `/agents → Settings → Scheduling`. When set to `false`: the `schedule` parameter and its guideline are stripped from the `Agent` tool spec at registration (zero LLM-context cost), the scheduler does not bind to the session, the `/agents → Scheduled jobs` menu entry is hidden, and any in-flight scheduler is stopped immediately. The schema-level removal applies on next pi session; the runtime kill (menu, fire path) takes effect immediately. Persisted at `<cwd>/.pi/subagents.json`.
|
||||
- **Schedule subagent spawns** — the `Agent` tool now accepts an optional `schedule` parameter. When set, the spawn registers a job that fires later instead of running immediately. Three formats: 6-field cron (`"0 0 9 * * 1"` — 9am every Monday), interval (`"5m"`, `"1h"`), or one-shot (`"+10m"` or ISO timestamp). Returns the job ID. Schedules are session-scoped — they reset on `/new`, restore on `/resume` (mirrors the persistence model of pi-chonky-tasks). Storage at `<cwd>/.pi/subagent-schedules/<sessionId>.json`, with PID-based file locking + atomic temp+rename for concurrent-instance safety. **Result delivery is identical to today's background-spawn completions**: when the scheduled agent finishes, the existing `subagent-notification` followUp path emits the result to the conversation — no new delivery code, no new message types. **Concurrency**: scheduled fires bypass `maxConcurrent` so a 5-minute interval can't be deferred behind 4 long-running manual agents. **Management**: `/agents` → "Scheduled jobs" lists active jobs and lets you cancel any one of them. Creation is via the `Agent` tool only — no parallel manual-create wizard in this iteration. **Events**: `subagents:scheduled` ({ type: "added" | "removed" | "updated" | "fired" | "error", … }) and `subagents:scheduler_ready` for cross-extension consumers. **Restrictions**: `schedule` is incompatible with `inherit_context` (no parent at fire time) and `resume` (schedules create fresh agents); forces `run_in_background: true`. Scheduler engine mirrors `pi-cron-schedule` (`croner` for cron, `setInterval`/`setTimeout` for interval/once); past one-shot timestamps and invalid cron expressions are caught at create time.
|
||||
- **Context-window utilization indicator in the subagent overlay** — token count is now followed by a colored `(NN%)` showing how full the subagent's context is right now (`estimateContextTokens(messages) / model.contextWindow * 100`, sourced from upstream `contextUsage.percent`). Threshold colors: <70% dim, 70–85% warning, ≥85% error. Gracefully omitted when the model has no `contextWindow` declared, or right after compaction before the next assistant turn (`tokens` is `null` in that window). The same annotation slot also surfaces a compaction count `↻N` when the agent has compacted at least once — e.g. `12.3k token (84% · ↻3)` (percent + compactions joined with `·`), `12.3k token (↻1)` (compactions only, immediately post-compaction while percent is still null). The compaction glyph stays dim regardless; the percent's threshold color carries the urgency signal. Two live overlays get the annotations (running stats line; inspect-overlay header); post-completion notifications and result/event payloads only get the count (the indicator is no longer actionable once the agent is done).
|
||||
- **Token usage and context% exposed to the parent agent** at every interaction surface — `get_subagent_result` adds `Context: NN%` to its stats line; `steer_subagent` returns a `Current state: 12.3k token · 5 tool uses · context 72% full` line so the steering agent knows whether it has room before sending more context; `task-notification` XML adds `<context_percent>NN</context_percent>` (omitted when null). All plain-text, no ANSI codes — designed for LLM consumption, not human display.
|
||||
- **New `subagents:compacted` lifecycle event** fires when a subagent's session successfully compacts. Payload: `{ id, type, description, reason: "manual" | "threshold" | "overflow", tokensBefore, compactionCount }` — `tokensBefore` is upstream's pre-compaction context size estimate; `compactionCount` is the running total for this agent (also persisted on `AgentRecord.compactionCount` and surfaced in `get_subagent_result` / `steer_subagent` / `task-notification` when > 0). Aborted compactions don't fire. Routed through a new manager-level `onCompact` constructor callback, matching the existing `onStart` / `onComplete` pattern.
|
||||
|
||||
### Fixed
|
||||
- **Subagent token count was inflated 5–15× and reset mid-run** ([#38](https://github.com/tintinweb/pi-subagents/issues/38)). Two distinct bugs in the same field. (1) Upstream `getSessionStats().tokens.total` sums per-turn `cacheRead` across every assistant message — but each turn's `cacheRead` is the *cumulative* cached prefix re-read on that one API call, so summing N turns counts the prefix N times (quadratic inflation, very visible on long sessions). (2) Even with that fixed, anything derived from `session.state.messages` resets at compaction because upstream replaces the array via `this.agent.state.messages = sessionContext.messages`. Fix replaces all six display readers with a lifetime accumulator (`AgentRecord.lifetimeUsage` and `AgentActivity.lifetimeUsage` — `{ input, output, cacheWrite }`) fed by a new `onAssistantUsage` callback dispatched from `message_end` events in both `runAgent` and `resumeAgent`. The accumulator is independent of `state.messages` mutation, so it survives compaction; total = input + output + cacheWrite by construction (cacheRead deliberately excluded — same prefix-double-counting reason). The `subagents:completed`/`failed` event payload's `tokens` field is now also lifetime-accumulated for `input`, `output`, and `total` together (was: `total` lifetime, `input`/`output` session-derived → inconsistent after compaction).
|
||||
- **ESC during a foreground `Agent` call now actually stops the subagent** ([#44](https://github.com/tintinweb/pi-subagents/pull/44) — thanks [@Zeng-Zer](https://github.com/Zeng-Zer)). Pi's interrupt path is `esc → agent.abort()` on the parent → `AbortSignal` delivered to every tool's `execute(toolCallId, params, signal, …)`, but the `Agent` tool dropped that signal on the floor: subagents ran on their own independent `AbortController` inside `AgentManager`, so the parent abort was invisible and the subagent kept running until natural completion or `max_turns`. Fix threads `signal` through `Agent.execute` → `manager.spawnAndWait()` → `SpawnOptions.signal`, and `AgentManager.startAgent()` now attaches an `{ once: true }` `"abort"` listener that calls `this.abort(id)` (which sets `status: "stopped"` and aborts the child controller). The listener is detached in both `.then` and `.catch` to avoid leaking on natural settle. **Scope:** foreground only — background agents intentionally outlive the parent tool call, so their spawn deliberately does not forward `signal`. Resume path (`AgentManager.resume()`) has the same blind spot and is tracked as a follow-up.
|
||||
|
||||
## [0.6.3] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
- **`run_in_background: true` (and `inherit_context`, `isolated`) silently ignored on default agents** ([#37](https://github.com/tintinweb/pi-subagents/issues/37) — thanks [@kylesnowschwartz](https://github.com/kylesnowschwartz) for the diagnosis). The three built-in defaults (`general-purpose`, `Explore`, `Plan`) baked `runInBackground: false`, `inheritContext: false`, and `isolated: false` into their configs. `resolveAgentInvocationConfig` uses `agentConfig?.field ?? params.field ?? false`, and `??` only falls through on `null`/`undefined` — so an explicit `false` from the agent config silently won over the caller's `true`. Calling `Agent({ subagent_type: "general-purpose", run_in_background: true })` returned the result inline instead of backgrounding, blocking the parent UI for the agent's full runtime. Fix drops the three lines from each default (and from the unreachable defensive fallback in `agent-runner.ts`) — the type already declared each as `field?: boolean` with JSDoc *"undefined = caller decides"*, so the runtime now matches the documented contract. **Behavior:** custom agents that explicitly set these fields in frontmatter still lock as before (the v0.5.1 "frontmatter is authoritative" guarantee is preserved); the fix only stops *defaults* from spuriously claiming an opinion on callsite-strategy fields they don't actually have. The unreachable fallback now spreads `DEFAULT_AGENTS.get("general-purpose")` instead of duplicating the config inline, so future drift is impossible.
|
||||
|
||||
## [0.6.2] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
- **`Agent` tool fails on Windows with `ENOENT` creating output directory** ([#27](https://github.com/tintinweb/pi-subagents/issues/27) — thanks [@sixnathan](https://github.com/sixnathan) for the diagnosis). The cwd-encoding regex in `output-file.ts` only handled POSIX `/` separators, so on Windows `cwd = "C:\\Users\\foo\\project"` survived unchanged and `path.join(tmpRoot, encoded, …)` produced an invalid nested-absolute path. Now extracts a small `encodeCwd()` helper that handles both `/` and `\\` separators, strips the Windows drive-letter prefix, and preserves UNC server/share segments. The `chmodSync(root, 0o700)` call is also wrapped in a try/catch that swallows errors only on Windows (where chmod is a no-op and can throw on some filesystems); on Unix the error still propagates so umask-defeating `0o700` enforcement is preserved.
|
||||
|
||||
## [0.6.1] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **Persistent `/agents` → Settings** ([#24](https://github.com/tintinweb/pi-subagents/issues/24)) — the four runtime tuning values (`maxConcurrent`, `defaultMaxTurns`, `graceTurns`, `defaultJoinMode`) now survive pi restarts via a two-file dual-scope model mirroring pi's own `SettingsManager`. Global `~/.pi/agent/subagents.json` provides machine-wide defaults (edit by hand; the menu never writes here); project `<cwd>/.pi/subagents.json` holds per-project overrides (written by `/agents` → Settings). Load merges both with project winning on conflicts. Invalid fields are silently dropped per field; malformed JSON emits a warning to stderr and falls back to defaults so startup always proceeds; write failures downgrade the settings toast to a warning with `(session only; failed to persist)` so changes aren't silently reverted on next restart.
|
||||
- **New lifecycle events** — `subagents:settings_loaded` (emitted once at extension init with the merged settings) and `subagents:settings_changed` (emitted on each `/agents` → Settings mutation with the new snapshot and a `persisted: boolean` flag so listeners can react to write failures).
|
||||
|
||||
### Fixed
|
||||
- **`AGENTS.md` / `CLAUDE.md` / `APPEND_SYSTEM.md` no longer leak into sub-agent prompts** ([#26](https://github.com/tintinweb/pi-subagents/pull/26) — thanks [@mikeyobrien](https://github.com/mikeyobrien) for the diagnosis). Upstream `buildSystemPrompt()` re-appends `contextFiles` and `appendSystemPrompt` *after* our `systemPromptOverride` runs, which silently defeated `prompt_mode: replace` and `isolated: true` — parent project context (e.g. autoresearch-mode blocks) was bleeding into fresh `Explore` / custom sub-agents regardless of frontmatter. Fix uses upstream's `noContextFiles: true` flag (skips the load entirely, introduced in pi 0.68) plus `appendSystemPromptOverride: () => []` (no flag equivalent for append sources). **Behavior change:** subagents no longer implicitly inherit parent `AGENTS.md`/`CLAUDE.md`/`APPEND_SYSTEM.md`. To get parent project context into a subagent, use `prompt_mode: append` (parent's already-built system prompt flows in via `systemPromptOverride`), or `inherit_context: true` (parent conversation), or inline the content into the agent's own frontmatter.
|
||||
- **Custom agent discovery respects `PI_CODING_AGENT_DIR`** ([#35](https://github.com/tintinweb/pi-subagents/pull/35), closes [#23](https://github.com/tintinweb/pi-subagents/issues/23) — thanks [@Amolith](https://github.com/Amolith) for the diagnosis). Two remaining hardcoded `~/.pi/agent/agents/` paths in `custom-agents.ts` and `index.ts` bypassed the env var, so users who relocated their agent directory (e.g. via `PI_CODING_AGENT_DIR`) still had global agents loaded from the default location and help text referencing the wrong path. Both now use upstream `getAgentDir()`, consistent with `agent-runner.ts` and `settings.ts`; tilde expansion is handled by upstream.
|
||||
|
||||
## [0.6.0] - 2026-04-24
|
||||
|
||||
> **⚠️ Breaking: drops support for `pi` < 0.68.** The upstream `pi-coding-agent` package shipped breaking API changes in v0.68 (and further ones in v0.70). This release migrates to `^0.70.2` and is **not** backward-compatible with hosts on `pi` 0.62–0.67. Users on those versions must upgrade their `pi` installation (`npm install -g @mariozechner/pi-coding-agent@latest`) before updating this extension.
|
||||
|
||||
### Changed
|
||||
- **Bumped peer `@mariozechner/pi-coding-agent` to `^0.70.2`** ([#28](https://github.com/tintinweb/pi-subagents/pull/28)) — crosses the v0.68 breaking-change line upstream. Specifically: tools are now passed as `string[]` (was `Tool[]`); `cwd`/`agentDir` are mandatory on `SettingsManager.create()` and `DefaultResourceLoader`; `session_switch` event renamed to `session_before_switch`; `ToolDefinition.params` widens to `unknown` under contextual typing, requiring `defineTool(...)`.
|
||||
- **Tool registrations wrapped with `defineTool(...)`** — preserves `TParams` inference so `execute` handlers get properly-typed `params` instead of `unknown`. Applies to the `Agent`, `get_subagent_result`, and `steer_subagent` tools.
|
||||
|
||||
### Removed
|
||||
- **Cwd-bound tool factory registry** — the internal `TOOL_FACTORIES` closure table and `create{Bash,Edit,Read,Write,Grep,Find,Ls}Tool` imports are gone. Exported helpers renamed: `getToolsForType(type, cwd)` → `getToolNamesForType(type)`, `getMemoryTools(cwd, set)` → `getMemoryToolNames(set)`, `getReadOnlyMemoryTools(cwd, set)` → `getReadOnlyMemoryToolNames(set)` — all returning `string[]` instead of `Tool[]`. The host binds cwd when resolving tool names, so the extension no longer instantiates tools directly.
|
||||
|
||||
### Fixed
|
||||
- **Subagent `SettingsManager` read wrong project settings in worktree mode** ([#30](https://github.com/tintinweb/pi-subagents/pull/30)) — `SettingsManager.create()` was called without arguments, defaulting `cwd` to `process.cwd()`. When the subagent's effective cwd differed (worktree isolation or explicit `cwd` override), its settings manager read `.pi/settings.json` from the parent's cwd rather than its own, diverging from the loader and session manager. Now passes `effectiveCwd` and `agentDir` explicitly, keeping all three managers consistent.
|
||||
|
||||
## [0.5.2] - 2026-03-26
|
||||
|
||||
### Fixed
|
||||
- **Extension `session_start` handlers now fire in subagent sessions** ([#20](https://github.com/tintinweb/pi-subagents/issues/20)) — `bindExtensions()` was never called on subagent sessions, so extensions that initialize state in `session_start` (e.g. loading credentials, setting up connections) silently failed at runtime. Tools appeared registered but were non-functional. Now calls `session.bindExtensions()` after tool filtering and before prompting, matching the lifecycle used by pi's interactive, print, and RPC modes. Also triggers `extendResourcesFromExtensions("startup")` so extension-provided skills and prompts are discovered.
|
||||
|
||||
## [0.5.1] - 2026-03-24
|
||||
|
||||
### Changed
|
||||
- **Agent config is authoritative** — frontmatter values for `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, and `isolation` now take precedence over `Agent` tool-call parameters. Tool-call params only fill fields the agent config leaves unspecified.
|
||||
- **`join_mode` is now a global setting only** — removed the per-call `join_mode` parameter from the `Agent` tool. Join behavior is configured via `/agents` → Settings → Join mode.
|
||||
- **`max_turns: 0` means unlimited** — agent files can now explicitly set `max_turns: 0` to lock unlimited turns. Previously `0` was silently clamped to `1`.
|
||||
|
||||
### Fixed
|
||||
- **Final subagent text preserved from non-streaming providers** — agents using providers that return the final message without streaming `text_delta` events no longer return empty results. Falls back to extracting text from the completed session history.
|
||||
- **`effectiveMaxTurns` passed to spawn calls** — previously `params.max_turns` was passed raw to both foreground and background spawn, bypassing the agent config entirely.
|
||||
|
||||
## [0.5.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
- **RPC stop handler** — new `subagents:rpc:stop` event bus RPC allows other extensions to stop running subagents by agent ID. Returns structured error ("Agent not found") on failure.
|
||||
- **`abort` in `SpawnCapable` interface** — cross-extension RPC consumers can now stop agents, not just spawn them.
|
||||
- **Live turn counter** — all agents now show a live turn count in the widget, inline result, and completion notification. With a turn limit: `⟳5≤30` (5 of 30 turns). Without: `⟳5`. Updates in real time as turns progress via `onTurnEnd` callback.
|
||||
- **Biome linting** — added [Biome](https://biomejs.dev/) for correctness linting (unused imports, suspicious patterns). Style rules disabled. Run `npm run lint` to check, `npm run lint:fix` to auto-fix.
|
||||
- **CI workflow** — GitHub Actions runs lint, typecheck, and tests on push to master and PRs.
|
||||
- **Auto-trigger parent turn on background completion** — background agent completion notifications now use `triggerTurn: true`, automatically prompting the parent agent to process results instead of waiting for user input.
|
||||
|
||||
### Changed
|
||||
- **Standardized RPC envelope** — cross-extension RPC handlers (`ping`, `spawn`, `stop`) now use a `handleRpc` wrapper that emits structured envelopes (`{ success: true, data }` / `{ success: false, error }`), matching pi-mono's `RpcResponse` convention.
|
||||
- **Protocol versioning via ping** — ping reply now includes `{ version: PROTOCOL_VERSION }` (currently v2). Callers can detect version mismatches and warn users to update.
|
||||
- **Default max turns is now unlimited** — subagents no longer have a 50-turn default cap. The default is unlimited (no turn limit), matching Claude Code's main loop behavior. Users can still set explicit limits per-agent via `max_turns` frontmatter or the Agent tool parameter, or globally via `/agents` → Settings (`0` = unlimited).
|
||||
- **Stale dist in published package** — added `prepublishOnly` hook to build fresh `dist/` on every `npm publish`.
|
||||
|
||||
### Fixed
|
||||
- **Tool name display** — `getAgentConversation` now reads `ToolCall.name` (the correct property) instead of `toolName`, resolving `[Tool: unknown]` in conversation viewer and verbose output.
|
||||
- **Env test CI failure** — `detectEnv` test assumed a branch name exists, but CI checks out detached HEAD. Split into separate tests for repo detection and branch detection with a controlled temp repo.
|
||||
|
||||
## [0.4.9] - 2026-03-18
|
||||
|
||||
### Fixed
|
||||
- **Conversation viewer crash in narrow terminals** ([#7](https://github.com/tintinweb/pi-subagents/issues/7)) — `buildContentLines()` in the live conversation viewer could return lines wider than the terminal when `wrapTextWithAnsi()` misjudged visible width on ANSI-heavy input (e.g. tool output with embedded escape codes, long URLs, wide tables). All content lines are now clamped with `truncateToWidth()` before returning. Same class of bug as the widget fix in v0.2.7, different component.
|
||||
|
||||
### Added
|
||||
- **Conversation viewer width-safety tests** — 17 tests covering `render()` and `buildContentLines()` across varied content (plain text, ANSI codes, unicode, tables, long URLs, narrow terminals). Includes mock-based regression tests that simulate upstream `wrapTextWithAnsi` returning overwidth lines, ensuring the safety net catches them.
|
||||
|
||||
## [0.4.8] - 2026-03-18
|
||||
|
||||
### Added
|
||||
- **Cross-extension RPC** — other pi extensions can spawn subagents via `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`). Emits `subagents:ready` on load.
|
||||
- **Session persistence for agent records** — completed agent records are persisted via `pi.appendEntry("subagents:record", ...)` for cross-extension history reconstruction.
|
||||
|
||||
### Fixed
|
||||
- **Background agent notification race condition** — `pi.sendMessage()` is fire-and-forget, so completion notifications sent eagerly from `onComplete` could not be retracted when `get_subagent_result` was called in the same turn. Notifications are now held behind a 200ms cancellable timer; `get_subagent_result` cancels the pending timer before it fires, eliminating duplicate notifications. Group notifications also re-check `resultConsumed` at send time so consumed agents are filtered out.
|
||||
|
||||
## [0.4.7] - 2026-03-17
|
||||
|
||||
### Added
|
||||
- **Custom notification renderer** — background agent completion notifications now render as styled, themed boxes instead of raw XML. Uses `pi.registerMessageRenderer()` with the `"subagent-notification"` custom message type. The LLM continues to receive `<task-notification>` XML via `content`; only the user-facing display changes.
|
||||
- **Group notification rendering** — group completions render each agent as its own styled block (icon, description, stats, result preview) instead of showing only the first agent.
|
||||
- **Output file streaming for background agents** — background agents now get the same output file transcript as foreground agents, with `onSessionCreated` wiring and proper cleanup on completion/error.
|
||||
- `NotificationDetails` type in `types.ts` — structured details for the notification renderer, with optional `others` array for group notifications.
|
||||
- `buildNotificationDetails()` helper — extracts renderer-facing details from an `AgentRecord`.
|
||||
|
||||
### Changed
|
||||
- **Notification delivery** — `sendIndividualNudge` and group notification now use `pi.sendMessage()` (custom message) instead of `pi.sendUserMessage()` (plain text), enabling renderer-controlled display.
|
||||
- **Steered status rendering** — steered agents show "completed (steered)" in the notification box instead of plain "completed".
|
||||
|
||||
### Fixed
|
||||
- **Output file cleanup on completion** — `agent-manager.ts` now calls `record.outputCleanup()` in both the success and error paths of agent completion, ensuring the streaming subscription is flushed and released.
|
||||
|
||||
## [0.4.6] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
- **Graceful shutdown aborts agents instead of blocking** — `session_shutdown` now calls `abortAll()` instead of `waitForAll()`, so the process exits immediately instead of hanging until all background agents complete. Agent results are undeliverable after shutdown anyway.
|
||||
|
||||
### Added
|
||||
- `abortAll()` method on `AgentManager` — stops all queued and running agents at once, returning the count of affected agents.
|
||||
|
||||
## [0.4.5] - 2026-03-16
|
||||
|
||||
### Changed
|
||||
- **Widget render-once pattern** — the widget callback is now registered once via `setWidget()` and subsequent updates use `requestRender()` instead of re-registering the entire widget on every `update()` call. Eliminates layout thrashing from repeated widget teardown/setup cycles.
|
||||
- **Status bar dedup** — `setStatus()` is now only called when the status text actually changes, avoiding redundant TUI updates.
|
||||
- **UICtx change detection** — `setUICtx()` detects context changes and forces widget re-registration, correctly handling session switches.
|
||||
|
||||
### Refactored
|
||||
- Extracted `renderWidget()` private method — moves all widget content rendering out of the `update()` closure into a standalone method that reads live state on each call.
|
||||
- `update()` is now a lightweight coordinator: counts agents, manages registration lifecycle, and triggers re-renders.
|
||||
|
||||
## [0.4.4] - 2026-03-16
|
||||
|
||||
### Fixed
|
||||
- **Race condition in `get_subagent_result` with `wait: true`** — `resultConsumed` is now set before `await record.promise`, preventing a redundant follow-up notification. Previously the `onComplete` callback (attached at spawn time via `.then()`) always fired before the await resumed, seeing `resultConsumed` as false.
|
||||
- **Stale agent records across sessions** — new `clearCompleted()` method removes all completed/stopped/errored agent records on `session_start` and `session_switch` events, so tasks from a prior session don't persist into a new one.
|
||||
- **`steer_subagent` race on freshly launched agents** — steering an agent before its session initialized silently dropped the message. Now steers are queued on the record and flushed once `onSessionCreated` fires.
|
||||
|
||||
### Changed
|
||||
- Extracted `removeRecord()` private helper in `AgentManager` — deduplicates dispose+delete logic between `cleanup()` and `clearCompleted()`.
|
||||
|
||||
### Added
|
||||
- 8 new tests covering `resultConsumed` race condition and `clearCompleted` behavior (185 total).
|
||||
|
||||
## [0.4.3] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Persistent agent memory** — new `memory` frontmatter field with three scopes: `"user"` (global `~/.pi/`), `"project"` (per-project `.pi/`), `"local"` (gitignored `.pi/`). Agents with write/edit tools get full read-write memory; read-only agents get a read-only fallback that injects existing MEMORY.md content without granting write access or creating directories.
|
||||
- **Git worktree isolation** — new `isolation: "worktree"` frontmatter field and Agent tool parameter. Creates a temporary `git worktree` so agents work on an isolated copy of the repo. On completion, changes are auto-committed to a `pi-agent-<id>` branch; clean worktrees are removed. Includes crash recovery via `pruneWorktrees()`.
|
||||
- **Skill preloading** — `skills` frontmatter now accepts a comma-separated list of skill names (e.g. `skills: planning, review`). Reads from `.pi/skills/` (project) then `~/.pi/skills/` (global), tries `.md`/`.txt`/bare extensions. Content injected into the system prompt as `# Preloaded Skill: {name}`.
|
||||
- **Tool denylist** — new `disallowed_tools` frontmatter field (e.g. `disallowed_tools: bash, write`). Blocks specified tools even if `builtinToolNames` or extensions would provide them. Enforced for both extension-enabled and extension-disabled agents.
|
||||
- **Prompt extras system** — new `PromptExtras` interface in `prompts.ts`; `buildAgentPrompt()` accepts optional memory and skill blocks appended in both `replace` and `append` modes.
|
||||
- `getMemoryTools()`, `getReadOnlyMemoryTools()` in `agent-types.ts`.
|
||||
- `buildMemoryBlock()`, `buildReadOnlyMemoryBlock()`, `isSymlink()`, `safeReadFile()` in `memory.ts`.
|
||||
- `preloadSkills()` in `skill-loader.ts`.
|
||||
- `createWorktree()`, `cleanupWorktree()`, `pruneWorktrees()` in `worktree.ts`.
|
||||
- `MemoryScope`, `IsolationMode` types; `memory`, `isolation`, `disallowedTools` fields on `AgentConfig`; `worktree`, `worktreeResult` fields on `AgentRecord`.
|
||||
- 177 total tests across 8 test files (41 new tests).
|
||||
|
||||
### Fixed
|
||||
- **Read-only agents no longer escalated to read-write** — enabling `memory` on a read-only agent (e.g. Explore) previously auto-added `write`/`edit` tools. Now the runner detects write capability and branches: read-write agents get full memory tools, read-only agents get read-only memory prompt with only the `read` tool added.
|
||||
- **Denylist-aware memory detection** — write capability check now accounts for `disallowedTools`. An agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory instead of broken read-write instructions.
|
||||
- **Worktree requires commits** — repos with no commits (empty HEAD) are now rejected early with a warning instead of failing silently at `git worktree add`.
|
||||
- **Worktree failure warning** — when worktree creation fails, a warning is prepended to the agent's prompt instead of silently falling through to the main cwd.
|
||||
- **No force-branch overwrite** — worktree cleanup appends a timestamp suffix on branch name conflict instead of using `git branch -f`.
|
||||
|
||||
### Security
|
||||
- **Whitelist name validation** — agent/skill names must match `^[a-zA-Z0-9][a-zA-Z0-9._-]*$`, max 128 chars. Rejects path traversal, leading dots, spaces, and special characters.
|
||||
- **Symlink protection** — `safeReadFile()` and `isSymlink()` reject symlinks in memory directories, MEMORY.md files, and skill files, preventing arbitrary file reads.
|
||||
- **Symlink-safe directory creation** — `ensureMemoryDir()` throws on symlinked directories.
|
||||
|
||||
### Changed
|
||||
- `agent-runner.ts`: tool/extension/skill resolution moved before memory detection; `ctx.cwd` → `effectiveCwd` throughout.
|
||||
- `custom-agents.ts`: extracted `parseCsvField()` helper; added `csvListOptional()` and `parseMemory()`.
|
||||
- `skill-loader.ts`: uses `safeReadFile()` from `memory.ts` instead of raw `readFileSync`.
|
||||
- Agent tool schema updated with `isolation` parameter and help text for `memory`, `isolation`, `disallowed_tools`, and skill list.
|
||||
|
||||
## [0.4.2] - 2026-03-12
|
||||
|
||||
### Added
|
||||
- **Event bus** — agent lifecycle events emitted via `pi.events.emit()`, enabling other extensions to react to sub-agent activity:
|
||||
- `subagents:created` — background agent registered (includes `id`, `type`, `description`, `isBackground`)
|
||||
- `subagents:started` — agent transitions to running (includes queued→running)
|
||||
- `subagents:completed` — agent finished successfully (includes `durationMs`, `tokens`, `toolUses`, `result`)
|
||||
- `subagents:failed` — agent errored, stopped, or aborted (same payload as completed)
|
||||
- `subagents:steered` — steering message sent to a running agent
|
||||
- `OnAgentStart` callback and `onStart` constructor parameter on `AgentManager`.
|
||||
- **Cross-package manager** now also exposes `spawn()` and `getRecord()` via the `Symbol.for("pi-subagents:manager")` global.
|
||||
|
||||
## [0.4.1] - 2026-03-11
|
||||
|
||||
### Fixed
|
||||
- **Graceful shutdown in headless mode** — the CLI now waits for all running and queued background agents to complete before exiting (`waitForAll` on `session_shutdown`). Previously, background agents could be silently killed mid-execution when the session ended. Only affects headless/non-interactive mode; interactive sessions already kept the process alive.
|
||||
|
||||
### Added
|
||||
- `hasRunning()` / `waitForAll()` methods on `AgentManager`.
|
||||
- **Cross-package manager access** — agent manager exposed via `Symbol.for("pi-subagents:manager")` on `globalThis` for other extensions to check status or await completion.
|
||||
|
||||
## [0.4.0] - 2026-03-11
|
||||
|
||||
### Added
|
||||
- **XML-delimited prompt sections** — append-mode agents now wrap inherited content in `<inherited_system_prompt>`, `<sub_agent_context>`, and `<agent_instructions>` XML tags, giving the model explicit structure to distinguish inherited rules from sub-agent-specific instructions. Replace mode is unchanged.
|
||||
- **Token count in agent results** — foreground agent results, background completion notifications, and `get_subagent_result` now include the token count alongside tool uses and duration (e.g. `Agent completed in 4.2s (12 tool uses, 33.8k token)`).
|
||||
- **Widget overflow cap** — the running agents widget now caps at 12 lines. When exceeded, running agents are prioritized over finished ones and an overflow summary line shows hidden counts (e.g. `+3 more (1 running, 2 finished)`).
|
||||
|
||||
### Changed - **changing behavior**
|
||||
- **General-purpose agent inherits parent prompt** — the default `general-purpose` agent now uses `promptMode: "append"` with an empty system prompt, making it a "parent twin" that inherits the full parent system prompt (including CLAUDE.md rules, project conventions, and safety guardrails). Previously it used a standalone prompt that duplicated a subset of the parent's rules. Explore and Plan are unchanged (standalone prompts). To customize: eject via `/agents` → select `general-purpose` → Eject, then edit the resulting `.md` file. Set `prompt_mode: replace` to go back to a standalone prompt, or keep `prompt_mode: append` and add extra instructions in the body.
|
||||
- **Append-mode agents receive parent system prompt** — `buildAgentPrompt` now accepts the parent's system prompt and threads it into append-mode agents (env header + parent prompt + sub-agent context bridge + optional custom instructions). Replace-mode agents are unchanged.
|
||||
- **Prompt pipeline simplified** — removed `systemPromptOverride`/`systemPromptAppend` from `SpawnOptions` and `RunOptions`. These were a separate code path where `index.ts` pre-resolved the prompt mode and passed raw strings into the runner, bypassing `buildAgentPrompt`. Now all prompt assembly flows through `buildAgentPrompt` using the agent's `promptMode` config — one code path, no special cases.
|
||||
|
||||
### Removed
|
||||
- Deprecated backwards-compat aliases: `registerCustomAgents`, `getCustomAgentConfig`, `getCustomAgentNames` (use `registerAgents`, `getAgentConfig`, `getUserAgentNames`).
|
||||
- `resolveCustomPrompt()` helper in index.ts — no longer needed now that prompt routing is config-driven.
|
||||
|
||||
## [0.3.1] - 2026-03-09
|
||||
|
||||
### Added
|
||||
- **Live conversation viewer** — selecting a running (or completed) agent in `/agents` → "Running agents" now opens a scrollable overlay showing the agent's full conversation in real time. Auto-scrolls to follow new content; scroll up to pause, End to resume. Press Esc to close.
|
||||
|
||||
## [0.3.0] - 2026-03-08
|
||||
|
||||
### Added
|
||||
- **Case-insensitive agent type lookup** — `"explore"`, `"EXPLORE"`, and `"Explore"` all resolve to the same agent. LLMs frequently lowercase type names; this prevents validation failures.
|
||||
- **Unknown type fallback** — unrecognized agent types fall back to `general-purpose` with a note, instead of hard-rejecting. Matches Claude Code behavior.
|
||||
- **Dynamic tool list for general-purpose** — `builtinToolNames` is now optional in `AgentConfig`. When omitted, the agent gets all tools from `TOOL_FACTORIES` at lookup time, so new tools added upstream are automatically available.
|
||||
- **Agent source indicators in `/agents` menu** — `•` (project), `◦` (global), `✕` (disabled) with legend. Defaults are unmarked.
|
||||
- **Disabled agents visible in UI** — disabled agents now show in the "Agent types" list (marked `✕`) with an Enable action, instead of being invisible.
|
||||
- **Enable action** — re-enable a disabled agent from the `/agents` menu. Stub files are auto-cleaned.
|
||||
- **Disable action for all agent types** — custom and ejected default agents can now be disabled from the UI, not just built-in defaults.
|
||||
- `resolveType()` export — case-insensitive type name resolution for external use.
|
||||
- `getAllTypes()` export — returns all agent names including disabled (for UI listing).
|
||||
- `source` field on `AgentConfig` — tracks where an agent was loaded from (`"default"`, `"project"`, `"global"`).
|
||||
|
||||
### Fixed
|
||||
- **Model resolver checks auth for exact matches** — `resolveModel("anthropic/claude-haiku-4-5-20251001")` now fails gracefully when no Anthropic API key is configured, instead of returning a model that errors at the API call. Explore silently falls back to the parent model on non-Anthropic setups.
|
||||
|
||||
### Changed
|
||||
- **Unified agent registry** — built-in and custom agents now use the same `AgentConfig` type and a single registry. No more separate code paths for built-in vs custom agents.
|
||||
- **Default agents are overridable** — creating a `.md` file with the same name as a default agent (e.g. `.pi/agents/Explore.md`) overrides it.
|
||||
- **`/agents` menu** — "Agent types" list shows defaults and custom agents together with source indicators. Default agents get Eject/Disable actions; overridden defaults get Reset to default.
|
||||
- **Eject action** — export a default agent's embedded config as a `.md` file to project or personal location for customization.
|
||||
- **Model labels** — provider-agnostic: strips `provider/` prefix and `-YYYYMMDD` date suffix (e.g. `anthropic/claude-haiku-4-5-20251001` → `claude-haiku-4-5`). Works for any provider.
|
||||
- **New frontmatter fields** — `display_name` (UI display name) and `enabled` (default: true; set to false to disable).
|
||||
- **Menu navigation** — Esc in agent detail returns to agent list (not main menu).
|
||||
|
||||
### Removed
|
||||
- **`statusline-setup` and `claude-code-guide` agents** — removed as built-in types (never spawned programmatically). Users can recreate them as custom agents if needed.
|
||||
- `BuiltinSubagentType` union type, `SUBAGENT_TYPES` array, `DISPLAY_NAMES` map, `SubagentTypeConfig` interface — replaced by unified `AgentConfig`.
|
||||
- `buildSystemPrompt()` switch statement — replaced by config-driven `buildAgentPrompt()`.
|
||||
- `HAIKU_MODEL_IDS` fallback array — Explore's haiku default is now just the `model` field in its config.
|
||||
- `BUILTIN_MODEL_LABELS` — model labels now derived from config.
|
||||
- `ALL_TOOLS` hardcoded constant — general-purpose now derives tools dynamically.
|
||||
|
||||
### Added
|
||||
- `src/default-agents.ts` — embedded default configs for general-purpose, Explore, and Plan.
|
||||
|
||||
## [0.2.7] - 2026-03-08
|
||||
|
||||
### Fixed
|
||||
- **Widget crash in narrow terminals** — agent widget lines were not truncated to terminal width, causing `doRender` to throw when the tmux pane was narrower than the rendered content. All widget lines are now truncated using `truncateToWidth()` with the actual terminal column count.
|
||||
|
||||
## [0.2.6] - 2026-03-07
|
||||
|
||||
### Added
|
||||
- **Background task join strategies** — smart grouping of background agent completion notifications
|
||||
- `smart` (default): 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification instead of individual nudges
|
||||
- `async`: each agent notifies individually on completion (previous behavior)
|
||||
- `group`: force grouping even for solo agents
|
||||
- 30s timeout after first completion delivers partial results; 15s straggler re-batch window for remaining agents
|
||||
- **`join_mode` parameter** on the `Agent` tool — override join strategy per agent (`"async"` or `"group"`)
|
||||
- **Join mode setting** in `/agents` → Settings — configure the default join mode at runtime
|
||||
- New `src/group-join.ts` — `GroupJoinManager` class for batched completion notifications
|
||||
|
||||
### Changed
|
||||
- `AgentRecord` now includes optional `groupId`, `joinMode`, and `resultConsumed` fields
|
||||
- Background agent completion routing refactored: individual nudge logic extracted to `sendIndividualNudge()`, group delivery via `GroupJoinManager`
|
||||
|
||||
### Fixed
|
||||
- **Debounce window race** — agents that complete during the 100ms batch debounce window are now deferred and retroactively fed into the group once it's registered, preventing split notifications (one individual + one partial group) and zombie groups
|
||||
- **Solo agent swallowed notification** — if only one agent was spawned (no group formed) but it completed during the debounce window, its deferred notification is now sent when the batch finalizes
|
||||
- **Duplicate notifications after polling** — calling `get_subagent_result` on a completed agent now marks its result as consumed, suppressing the subsequent completion notification (both individual and group)
|
||||
|
||||
## [0.2.5] - 2026-03-06
|
||||
|
||||
### Added
|
||||
- **Interactive `/agents` menu** — single command replaces `/agent` and `/agents` with a full management wizard
|
||||
- Browse and manage running agents
|
||||
- Custom agents submenu — edit or delete existing agents
|
||||
- Create new custom agents via manual wizard or AI-generated (with comprehensive frontmatter documentation for the generator)
|
||||
- Settings: configure max concurrency, default max turns, and grace turns at runtime
|
||||
- Built-in agent types shown with model info (e.g. `Explore · haiku`)
|
||||
- Aligned formatting for agent lists
|
||||
- **Configurable turn limits** — `defaultMaxTurns` and `graceTurns` are now runtime-adjustable via `/agents` → Settings
|
||||
- Sub-menus return to main menu instead of exiting
|
||||
|
||||
### Removed
|
||||
- `/agent <type> <prompt>` command (use `Agent` tool directly, or create custom agents via `/agents`)
|
||||
|
||||
## [0.2.4] - 2026-03-06
|
||||
|
||||
### Added
|
||||
- **Global custom agents** — agents in `~/.pi/agent/agents/*.md` are now discovered automatically and available across all projects
|
||||
- Two-tier discovery hierarchy: project-level (`.pi/agents/`) overrides global (`~/.pi/agent/agents/`)
|
||||
|
||||
## [0.2.3] - 2026-03-05
|
||||
|
||||
### Added
|
||||
- Screenshot in README
|
||||
|
||||
## [0.2.2] - 2026-03-05
|
||||
|
||||
### Changed
|
||||
- Renamed package to `@tintinweb/pi-subagents`
|
||||
- Fuzzy model resolver now only matches models with auth configured (prevents selecting unconfigured providers)
|
||||
- Custom agents hot-reload on each `Agent` tool call (no restart needed for new `.pi/agents/*.md` files)
|
||||
- Updated pi dependencies to 0.56.1
|
||||
|
||||
### Refactored
|
||||
- Extracted `createActivityTracker()` — eliminates duplicated tool activity wiring between foreground and background paths
|
||||
- Extracted `safeFormatTokens()` — replaces 4 repeated try-catch blocks
|
||||
- Extracted `buildDetails()` — consolidates AgentDetails construction
|
||||
- Extracted `getStatusLabel()` / `getStatusNote()` — consolidates 3 duplicated status formatting chains
|
||||
- Shared `extractText()` — consolidated duplicate from context.ts and agent-runner.ts
|
||||
- Added `ERROR_STATUSES` constant in widget for consistent status checks
|
||||
- `getDisplayName()` now delegates to `getConfig()` instead of separate lookups
|
||||
- Removed unused `Tool` type export from agent-types
|
||||
|
||||
## [0.2.1] - 2026-03-05
|
||||
|
||||
### Added
|
||||
- **Persistent above-editor widget** — tree view of all running/queued/finished agents with animated spinners and live stats
|
||||
- **Concurrency queue** — configurable max concurrent background agents (default: 4), auto-drain
|
||||
- **Queued agents** collapsed to single summary line in widget
|
||||
- **Turn-based widget linger** — completed agents clear after 1 turn, errors/aborted linger for 2 extra turns
|
||||
- **Colored status icons** — themed rendering via `setWidget` callback form (`✓` green, `✓` yellow, `✗` red, `■` dim)
|
||||
- **Live response streaming** — `onTextDelta` shows truncated agent response text instead of static "thinking..."
|
||||
|
||||
### Changed
|
||||
- Tool names match Claude Code: `Agent`, `get_subagent_result`, `steer_subagent`
|
||||
- Labels use "Agent" / "Agents" (not "Subagent")
|
||||
- Widget heading: `●` when active, `○` when only lingering finished agents
|
||||
- Extracted all UI code to `src/ui/agent-widget.ts`
|
||||
|
||||
## [0.2.0] - 2026-03-05
|
||||
|
||||
### Added
|
||||
- **Claude Code-style UI rendering** — `renderCall`/`renderResult`/`onUpdate` for live streaming progress
|
||||
- Live activity descriptions: "searching, reading 3 files…"
|
||||
- Token count display: "33.8k token"
|
||||
- Per-agent tool use counter
|
||||
- Expandable completed results (ctrl+o)
|
||||
- Distinct states: running, background, completed, error, aborted
|
||||
- **Async environment detection** — replaced `execSync` with `pi.exec()` for non-blocking git/platform detection
|
||||
- **Status bar integration** — running background agent count shown in pi's status bar
|
||||
- **Fuzzy model selection** — `"haiku"`, `"sonnet"` resolve to best matching available model
|
||||
|
||||
### Changed
|
||||
- Tool label changed from "Spawn Agent" to "Agent" (matches Claude Code style)
|
||||
- `onToolUse` callback replaced with richer `onToolActivity` (includes tool name + start/end)
|
||||
- `onSessionCreated` callback for accessing session stats (token counts)
|
||||
- `env.ts` now requires `ExtensionAPI` parameter (async `pi.exec()` instead of `execSync`)
|
||||
|
||||
## [0.1.0] - 2026-03-05
|
||||
|
||||
Initial release.
|
||||
|
||||
### Added
|
||||
- **Autonomous sub-agents** — spawn specialized agents via tool call, each running in an isolated pi session
|
||||
- **Built-in agent types** — general-purpose, Explore (defaults to haiku), Plan, statusline-setup, claude-code-guide
|
||||
- **Custom user-defined agents** — define agents in `.pi/agents/<name>.md` with YAML frontmatter + system prompt body
|
||||
- **Frontmatter configuration** — tools, extensions, skills, model, thinking, max_turns, prompt_mode, inherit_context, run_in_background, isolated
|
||||
- **Graceful max_turns** — steer message at limit, 5 grace turns, then hard abort
|
||||
- **Background execution** — `run_in_background` with completion notifications
|
||||
- **`get_subagent_result` tool** — check status, wait for completion, verbose conversation output
|
||||
- **`steer_subagent` tool** — inject steering messages into running agents mid-execution
|
||||
- **Agent resume** — continue a previous agent's session with a new prompt
|
||||
- **Context inheritance** — fork the parent conversation into the sub-agent
|
||||
- **Model override** — per-agent model selection
|
||||
- **Thinking level** — per-agent extended thinking control
|
||||
- **`/agent` and `/agents` commands**
|
||||
|
||||
[0.6.3]: https://github.com/tintinweb/pi-subagents/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/tintinweb/pi-subagents/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://github.com/tintinweb/pi-subagents/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/tintinweb/pi-subagents/compare/v0.5.2...v0.6.0
|
||||
[0.5.2]: https://github.com/tintinweb/pi-subagents/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/tintinweb/pi-subagents/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/tintinweb/pi-subagents/compare/v0.4.9...v0.5.0
|
||||
[0.4.9]: https://github.com/tintinweb/pi-subagents/compare/v0.4.8...v0.4.9
|
||||
[0.4.8]: https://github.com/tintinweb/pi-subagents/compare/v0.4.7...v0.4.8
|
||||
[0.4.7]: https://github.com/tintinweb/pi-subagents/compare/v0.4.6...v0.4.7
|
||||
[0.4.6]: https://github.com/tintinweb/pi-subagents/compare/v0.4.5...v0.4.6
|
||||
[0.4.5]: https://github.com/tintinweb/pi-subagents/compare/v0.4.4...v0.4.5
|
||||
[0.4.4]: https://github.com/tintinweb/pi-subagents/compare/v0.4.3...v0.4.4
|
||||
[0.4.3]: https://github.com/tintinweb/pi-subagents/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/tintinweb/pi-subagents/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
|
||||
[0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/tintinweb/pi-subagents/compare/v0.2.7...v0.3.0
|
||||
[0.2.7]: https://github.com/tintinweb/pi-subagents/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/tintinweb/pi-subagents/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/tintinweb/pi-subagents/compare/v0.2.4...v0.2.5
|
||||
[0.2.4]: https://github.com/tintinweb/pi-subagents/compare/v0.2.3...v0.2.4
|
||||
[0.2.3]: https://github.com/tintinweb/pi-subagents/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/tintinweb/pi-subagents/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/tintinweb/pi-subagents/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/tintinweb/pi-subagents/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/tintinweb/pi-subagents/releases/tag/v0.1.0
|
||||
@@ -0,0 +1,68 @@
|
||||
# Contributing to @tintinweb/pi-subagents
|
||||
|
||||
This guide exists to save both sides time.
|
||||
|
||||
## Philosophy
|
||||
|
||||
`pi-subagents` is a [pi](https://pi.dev) extension, and it tries to stay focused:
|
||||
spawn and orchestrate autonomous sub-agents that feel native to pi, and do that
|
||||
well. Features that don't serve that goal, or that bolt on unrelated complexity,
|
||||
are likely to be declined. When in doubt, open an issue and discuss the idea
|
||||
before writing the code.
|
||||
|
||||
The extension deliberately mirrors Claude Code's tool names, calling
|
||||
conventions, and UI patterns. Changes should respect that compatibility unless
|
||||
there's a good reason to diverge.
|
||||
|
||||
## The One Rule
|
||||
|
||||
**You must understand your code.** If you cannot explain what your changes do and
|
||||
how they interact with the rest of the system, the PR will be closed.
|
||||
|
||||
Using AI to write code is fine. Submitting AI-generated slop without
|
||||
understanding it is not.
|
||||
|
||||
## Filing Issues
|
||||
|
||||
Keep issues short, concrete, and worth reading.
|
||||
|
||||
- Keep it concise. If it does not fit on one screen, it is too long.
|
||||
- Write in your own voice. If you used an LLM to draft it, review and shape it
|
||||
yourself before posting.
|
||||
- State the bug or request clearly, and explain why it matters.
|
||||
- For bugs, include a minimal repro: pi version, this extension's version, your
|
||||
agent/config, the steps, and the actual vs. expected behavior.
|
||||
- If you want to implement the change yourself, say so.
|
||||
|
||||
For security issues, do **not** open a public issue — see [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Before Submitting a PR
|
||||
|
||||
For anything beyond a trivial fix, open an issue first so we can agree on the
|
||||
approach before you invest the time.
|
||||
|
||||
Make sure the full check suite passes locally:
|
||||
|
||||
```bash
|
||||
npm run lint # biome
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test # vitest
|
||||
npm run build # tsc
|
||||
```
|
||||
|
||||
All four must pass. `npm run lint:fix` will auto-fix most style issues, and
|
||||
`npm run test:e2e` runs the end-to-end suite if your change touches that surface.
|
||||
|
||||
Other guidelines:
|
||||
|
||||
- Keep PRs focused — one logical change per PR. Unrelated refactors make review
|
||||
harder and are likely to be split out or declined.
|
||||
- Add or update tests for behavior you change.
|
||||
- Match the surrounding code style (enforced by biome).
|
||||
- Do not edit `CHANGELOG.md`. Changelog entries are added by the maintainer.
|
||||
- Update the README when you add or change user-facing behavior.
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an [issue](https://github.com/tintinweb/pi-subagents/issues) — questions and
|
||||
discussion are welcome.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tintinweb
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,866 @@
|
||||
# @tintinweb/pi-subagents
|
||||
|
||||
A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in the background (the default) or block on them, steer them mid-run, resume completed sessions, and define your own custom agent types.
|
||||
|
||||
> **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. The root bundle installs it through `file:./pi-subagents`, default-loads the host-owned `extensions/subagents.ts` wrapper, and exposes its orchestration tools through Tool Search's checked-in `subagents` group. The local source implements the `pi-permission-system` child lifecycle bridge described below. See [`UPSTREAM.md`](UPSTREAM.md).
|
||||
|
||||
## my-pi permission integration
|
||||
|
||||
Before a child calls `bindExtensions()`, it publishes `subagents:child:session-created` with the child session id and the interactive root session id. This lets `pi-permission-system` classify the headless session as a registered child before its own `session_start`, prevents the child permission service from replacing the root process-global service, and routes unresolved `ask` requests to the root authority. Nested descendants retain the same interactive root instead of forwarding to an intermediate child with no UI. The matching `subagents:child:disposed` event is published once, after the child has actually closed.
|
||||
|
||||
The bundle loads this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`. These exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list.
|
||||
|
||||
Each child system prompt carries one sanitized `<active_agent name="..."/>` identity. Any inherited parent identity is removed first, so per-agent permission configuration and forwarded-request attribution resolve against the child definition rather than the orchestrator.
|
||||
|
||||
The off-screen conversation clone used by `@agent-…` mention handling disables extension, skill, prompt-template, and theme discovery and registers only its synthetic `Agent` tool. This prevents the root bundle's Tool Search lifecycle from replacing the clone's one-tool allowlist while keeping delegated agents themselves on the normal mandatory permission lifecycle.
|
||||
|
||||
Permission decisions remain owned by `pi-permission-system`:
|
||||
|
||||
- hard `deny` and deterministic `allow` stay local to the child's policy gate;
|
||||
- an `ask` is forwarded to the root session rather than reviewed inside the child;
|
||||
- root whole-session rules are matched against the child-fixed access facts before another review;
|
||||
- root auto-review uses the root transcript, where direct human messages remain distinguishable from assistant-generated delegation prompts;
|
||||
- reviewer `defer`, reviewer failure, or an approval outside the delegation envelope falls through to the root human authority;
|
||||
- a one-shot approval is not copied into a reusable descendant grant, so executing the same side effect twice still requires two decisions.
|
||||
|
||||
The lifecycle channel names are an event contract only. This package does not import permission-system internals or make policy decisions itself.
|
||||
|
||||
<img width="600" alt="pi-subagents screenshot" src="https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png" />
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
|
||||
|
||||
<img width="600" alt="pi-color-badges-white" src="https://github.com/user-attachments/assets/555dcae4-333e-4ff0-b420-7b3369c018a4" />
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
|
||||
- **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 10) and smart group join (consolidated notifications)
|
||||
- **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off`
|
||||
- **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
|
||||
- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) — the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too
|
||||
- **Custom agent types** — define agents in `.pi/agents/<name>.md` or `.agents/agents/<name>.md` (project) or globally, with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions, and Claude Code-compatible colored name badges
|
||||
- **Nested subagents** — opt-in, default-off delegation: a custom agent that sets `allowed_subagents` gets its own ownership-scoped `Agent`, `get_subagent_result`, and `steer_subagent` tools, depth-capped from the main session (default 2). It can control only its own children, they are stopped when it finishes, and their transcripts and token spend roll up to it. The allowlist is a privilege boundary — a child runs with its own tools, so pick it as carefully as `tools:` itself
|
||||
- **Agent mentions** — subagents are first-class: type `@explore also check the RPC path` at the prompt and it goes to that agent instead of the main model, without a word of it entering the chat. One syntax covers the whole lifecycle — message it while it runs, resume it once it has finished, reopen its session from disk long after that, or start it if it never ran. Mentioning an agent that isn't running spawns it through an off-screen clone of the conversation, so it gets Claude Code's context-written prompt and a real `Agent` tool call without a word of it reaching the chat; `direct` mode starts it here from your text instead, with no model call at all. The orchestrator can `name` an agent so you address it as `@auth-audit`, and handles work in `steer_subagent`/`get_subagent_result` too. `@` completes live agents, resumable ones, and startable types alongside pi's file completion; `@main` forces text back to the main model. Toggle via `/agents → Settings → Agent mentions`
|
||||
- **Mid-run steering** — inject messages into running agents to redirect their work without restarting
|
||||
- **Session resume** — pick up where an agent left off, preserving full conversation context. Resumes detached by default and notifies you on completion, just like a fresh spawn; pass `run_in_background: false` to block and get the result inline
|
||||
- **Graceful turn limits** — agents get a "wrap up" warning before hard abort, producing clean partial results instead of cut-off output
|
||||
- **Case-insensitive agent types** — `"explore"`, `"Explore"`, `"EXPLORE"` all work. A type that doesn't resolve to exactly one *enabled* agent — unknown, disabled, or ambiguous between two agents differing only by case — falls back to general-purpose with a note, or is refused outright under [`fallbackSubagent: none`](#persistent-settings)
|
||||
- **Fuzzy model selection** — specify models by name (`"haiku"`, `"sonnet"`) instead of full IDs, with automatic filtering to only available/configured models
|
||||
- **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed
|
||||
- **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools
|
||||
- **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
|
||||
- **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
|
||||
- **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
|
||||
- **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
|
||||
- **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
|
||||
- **Cross-extension RPC** — other pi extensions can spawn and stop subagents via the `pi.events` event bus (`subagents:rpc:ping`, `subagents:rpc:spawn`, `subagents:rpc:stop`). Standardized reply envelopes with protocol versioning. Emits `subagents:ready` on session start
|
||||
- **Schedule subagents** — pass `schedule` to the `Agent` tool to fire on cron / interval / one-shot. Session-scoped jobs with PID-locked persistence; results land via the same `subagent-notification` followUp path as manual background completions; manage via `/agents → Scheduled jobs`
|
||||
- **Model scope enforcement** — opt-in validation that subagent model choices stay within your pi `enabledModels` allowlist (sourced from `/scoped-models`, with both global and project-local pi settings honored). Caller-supplied out-of-scope → hard error to orchestrator; frontmatter-pinned out-of-scope → warning + runs anyway (frontmatter authoritative). Toggle via `/agents → Settings → Scope models`
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pi install npm:@tintinweb/pi-subagents
|
||||
```
|
||||
|
||||
Or load directly for development:
|
||||
|
||||
```bash
|
||||
pi -e ./src/index.ts
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
The parent agent spawns sub-agents using the `Agent` tool:
|
||||
|
||||
```
|
||||
Agent({
|
||||
subagent_type: "Explore",
|
||||
prompt: "Find all files that handle authentication",
|
||||
description: "Find auth files",
|
||||
run_in_background: true,
|
||||
})
|
||||
```
|
||||
|
||||
Agents run in the background by default: the call returns an ID immediately and notifies you on completion, carrying a preview of the result (use `get_subagent_result` for the full text). Pass `run_in_background: false` to block until the agent finishes and get its full output inline.
|
||||
|
||||
### Scheduling
|
||||
|
||||
Add a `schedule` field to register the agent to fire later instead of running now:
|
||||
|
||||
```
|
||||
Agent({
|
||||
subagent_type: "Explore",
|
||||
prompt: "Look at recent commits and summarize what changed since last week",
|
||||
description: "Weekly commit review",
|
||||
schedule: "0 0 9 * * 1", // 9am every Monday (6-field cron)
|
||||
})
|
||||
```
|
||||
|
||||
Schedule formats:
|
||||
|
||||
- **Cron** — 6-field (`second minute hour day-of-month month day-of-week`), e.g. `"0 0 9 * * 1"` for 9am every Monday, `"0 */15 * * * *"` for every 15 minutes.
|
||||
- **Interval** — `"5m"`, `"1h"`, `"30s"`, `"2d"`. Fires repeatedly at that interval.
|
||||
- **One-shot relative** — `"+10m"`, `"+2h"`, `"+1d"`. Fires once at that future time.
|
||||
- **One-shot absolute** — full ISO timestamp, e.g. `"2026-12-25T09:00:00.000Z"`.
|
||||
|
||||
When a schedule fires, the spawn runs in background and its completion notification arrives in the conversation through the same `subagent-notification` followUp path as a manually-spawned background agent — your parent agent reasons about the result the same way.
|
||||
|
||||
Schedules are **session-scoped**: they reset on `/new` and restore on `/resume`. List and cancel via `/agents → Scheduled jobs` (creation is the `Agent` tool's job — there is no parallel manual-create wizard). Storage at `<cwd>/.pi/subagent-schedules/<sessionId>.json` with PID-based file locking for cross-instance safety.
|
||||
|
||||
**Disable the feature entirely**: `/agents → Settings → Scheduling → disabled` removes `schedule` from the `Agent` tool spec (no LLM-context cost), hides the menu entry, and stops any active scheduler. The schema-level removal takes effect on the next pi session; the runtime kill is immediate. Re-enable from the same menu.
|
||||
|
||||
Restrictions:
|
||||
- `schedule` cannot be combined with `inherit_context` (no parent conversation exists at fire time) or `resume` (schedules create fresh agents).
|
||||
- `run_in_background: false` is refused — scheduled jobs always run in the background. Omitting it, or passing `true`, is fine.
|
||||
- Scheduled fires bypass the `maxConcurrent` queue so a 5-minute interval cannot be deferred behind long-running manual agents.
|
||||
- **Headless `pi -p` doesn't wait for scheduled subagents.**
|
||||
|
||||
## UI
|
||||
|
||||
The extension renders a persistent widget above the editor showing active agents. By default it shows background runs only (`widgetMode: background`) — foreground agents already render inline as the `Agent` tool result, so the widget would otherwise double-render them. Switch to `all` (every agent) or `off` (hide the widget) via `/agents → Settings → Widget`:
|
||||
|
||||
```
|
||||
● Agents
|
||||
├─ ⠹ Agent Refactor auth module · ↻5≤30 · 5 tool uses · 33.8k token (62%) · 12.3s
|
||||
│ ⎿ editing 2 files…
|
||||
├─ ⠹ Explore Find auth files · ↻3 · 3 tool uses · 12.4k token (8%) · 4.1s
|
||||
│ ⎿ searching…
|
||||
├─ ⠹ Agent Long-running task · ↻42 · 38 tool uses · 91.0k token (84% · ⇊2) · 2m17s
|
||||
│ ⎿ reading…
|
||||
└─ 2 queued
|
||||
```
|
||||
|
||||
The token field is annotated with two optional signals inside parens:
|
||||
- **`NN%`** — context-window utilization (color-coded: <70% dim, 70–85% warning, ≥85% error). Omitted when the model has no declared `contextWindow`, or briefly right after compaction.
|
||||
- **`⇊N`** — number of times the session has compacted, when > 0. Stays dim; the percent's color carries urgency.
|
||||
|
||||
### FleetView
|
||||
|
||||
While subagents are running, a Claude Code-style navigable list renders **below** the editor:
|
||||
|
||||
```
|
||||
esc to interrupt · ← for agents · ↓ to manage
|
||||
|
||||
● main
|
||||
○ general-purpose Sleep then report 1 11s · ↓ 13.1k tokens
|
||||
○ general-purpose Sleep then report 2 11s · ↓ 13.1k tokens
|
||||
↓ 3 more
|
||||
```
|
||||
|
||||
The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press `↓` (or `←`) to move focus from the prompt into the list — the selected row is marked `●`, the rest `○`. The selected row renders in the theme's primary text color rather than the muted/dim treatment of the others; an agent with a configured `color` shows its badge there too, bolded. `↑`/`↓` move the selection, `Enter` opens the selected agent's live conversation overlay (it auto-updates as the agent works), and `Esc` (or `↑` above `main`) returns to the prompt. Selecting `main` returns to the normal view. Inside the overlay, press `Enter` to steer the running agent — type a message and `Enter` to send it (`Esc` or an empty submit returns), and it redirects the agent the same way the `steer_subagent` tool does. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures arrow keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`.
|
||||
|
||||
### Agent mentions
|
||||
|
||||
Subagents are addressable. Every agent has a typeable handle — the agent type, lowercased, numbered when instances collide (`explore`, `explore-2`) — and `@handle <message>` at the prompt talks to that agent, whatever state it happens to be in. Type `@` to pick one:
|
||||
|
||||
```
|
||||
❯ @
|
||||
@auth-audit send message · Explore · running · audit the auth flow
|
||||
@explore-2 send message · running · find flaky tests
|
||||
@code-review resume · code-review · check the diff
|
||||
@plan start agent · Software architect agent for designing implementation plans.
|
||||
index.ts src/index.ts ← pi's own file rows, still there
|
||||
index.d.ts dist/index.d.ts
|
||||
```
|
||||
|
||||
The handle names the **agent**, not one process, so a single syntax covers its whole lifecycle:
|
||||
|
||||
| State | `@explore fix the flaky test` does |
|
||||
|-------|-----------------------------------|
|
||||
| running or queued | sends the message into its conversation, exactly as `steer_subagent` would |
|
||||
| finished | **resumes** it in the background from its existing session, continuing where it left off |
|
||||
| finished long ago, record gone | **reopens** its session from disk and continues there |
|
||||
| never started | **starts** it — by default, through a clone of this conversation ([below](#starting-a-new-agent)) |
|
||||
|
||||
No turn is ever spent in the main conversation, and nothing about the mention enters the chat. The answer comes back as the ordinary background-completion notification, so the main model can relay it.
|
||||
|
||||
#### Starting a new agent
|
||||
|
||||
Claude Code does not start a mentioned agent itself. `@agent-<type>` becomes an *attachment* appending a `<system-reminder>` to your prompt — "the user has expressed a desire to invoke the agent X; please invoke the agent appropriately, passing in the required context to it" — and the main model makes the tool call. There is no tool forcing and no allowed-tools narrowing: the mention constrains *which* agent, not what it is told. So the model writes the agent's prompt, giving it the conversation context a cold spawn lacks.
|
||||
|
||||
The cost is a visible turn — the model's reasoning and its tool block, narrating a decision you already made by typing the handle. This extension keeps the mechanism and moves it off-screen. The conversation is copied into a throwaway in-memory session, that clone takes the turn holding only the `Agent` tool, and what it starts is an ordinary top-level agent:
|
||||
|
||||
```
|
||||
@cyan whats your favorite color → (nothing in the chat)
|
||||
└─ clone of this conversation, off-screen
|
||||
└─ Agent(subagent_type: "cyan", prompt: …)
|
||||
▸ Cyan Agent favorite color ← widget, fleet row, handle
|
||||
```
|
||||
|
||||
It is a literal clone — the session's own entries and the same system prompt, not [`inherit_context`](#agent-frontmatter)'s text rendering of them — taken from memory and compaction-aware, so what the copy reads is what the main model is working from. The clone gets one tool and one job; it cannot read, write or run anything, because an invisible turn with the full toolset could do invisible work. The agent it starts is attributed to the *real* session, so its transcript and `rootSessionId` land where they would have anyway, and it carries no `tool-use-id` — the main conversation never issued one.
|
||||
|
||||
| Mode | `@plan sketch the migration`, with no Plan agent running |
|
||||
|------|----------------------------------------------------------|
|
||||
| `model` (default) | a clone of this conversation takes the turn off-screen and calls `Agent`, so the agent starts with a prompt **written from the conversation**. Nothing reaches the chat but a `Prompting @plan…` toast — the wording marks the wait for that turn, where `direct`'s `Started @plan` means it is already running |
|
||||
| `direct` | the agent starts here, immediately, with your message verbatim as its prompt. No model call at all, so no latency before it begins |
|
||||
| `off` | `@` means only "attach a file" again |
|
||||
|
||||
Either way the started agent honours its own frontmatter — `model:`, `thinking:`, `max_turns:` all apply, since neither path passes them and the agent's config wins. Mentioning something as the very first thing in a session works: there is simply no history to carry, and the clone still runs on your model and system prompt. If it cannot deliver at all — a model can always answer in prose instead of calling the tool — the agent is started directly with your text and the toast says so, rather than leaving you with nothing running.
|
||||
|
||||
`model` is also the only mode that works outside the TUI: `pi -p '@plan the migration'` clones, spawns, and reports through the normal completion path, where a direct start would have detached the agent and printed nothing. Messaging and resuming stay TUI-only for that reason, in both modes.
|
||||
|
||||
Two things to weigh against `direct`: the clone re-sends the whole conversation, and the agent does not start until that turn finishes.
|
||||
|
||||
**Named agents.** The `Agent` tool takes an optional `name`, so the orchestrator can call one `auth-audit` instead of leaving you to tell `@explore-2` from `@explore-3`. A name is *additive*: the type-derived handle is still assigned, so `@explore` keeps reaching that agent rather than starting a second one beside it. Both names share one namespace — an alias can never shadow a live handle or the reverse — and the popup shows one row per agent, under its alias, with the type moved into the description. `steer_subagent` and `get_subagent_result` accept a handle too, so you and the model address agents the same way.
|
||||
|
||||
**Resuming much later.** Because subagent sessions are persisted by default ([`rememberAgents`](#persistent-settings)), a handle keeps working after the agent's in-memory record is evicted: `@explore anything else?` reopens the conversation from disk. Only the *definition* is re-resolved, so a continuation runs under the agent type's current frontmatter, not the one the first run used. If the type has since been deleted or disabled, the resume is refused rather than falling back to another agent — re-enable it and the handle works again. Names from an evicted agent stay reserved, so a later Explore becomes `explore-2` rather than shadowing something you can still reach; the 100 most recent are kept, and all of them are forgotten on `/new` and session switch. A resumed agent takes those names back, so `@explore` keeps meaning the same conversation. An agent whose session was only ever in memory leaves nothing to reopen, and the mention starts a fresh one instead; if the session file has since been deleted, the mention says so and frees the handle rather than silently sending your message to a new agent.
|
||||
|
||||
The grammar mirrors Claude Code's, and is deliberately narrow so nothing gets swallowed by accident:
|
||||
|
||||
| Input | Goes to |
|
||||
|-------|---------|
|
||||
| `@explore fix the flaky test` | the `explore` agent |
|
||||
| `@agent-explore fix the flaky test` | the same agent — Claude Code's manual spelling, accepted as a synonym |
|
||||
| `@main @explore is not a mention` | the main model, with `@main ` stripped — the escape hatch |
|
||||
| `@explore` (no message) | the main model — a bare handle is never a send |
|
||||
| `hey @explore look at this` | the main model — only a **leading** mention is routed |
|
||||
| `@src/index.ts summarize this` | the main model, with pi's normal file attachment |
|
||||
| `@nosuchagent hello` | the main model, verbatim — no agent, no type, no interception |
|
||||
|
||||
While an agent is live its handle addresses *it*, so `@explore` never starts a second Explore alongside a running one — use the `Agent` tool for deliberate parallelism. `@<agent-id>` works too. `main` is reserved and can never be an agent's handle (a type slugging to it gets `main-2`); handles are capped at 64 characters. A handle written as typed always wins over the `@agent-` form, so an agent genuinely called `agent-explore` stays reachable. [Nested subagents](#nested-subagents) are not addressable — they are hidden from every top-level surface and only their owner may steer them, so a handle that would name one starts a fresh top-level agent instead of reaching through that boundary. Suggestions list live agents first, then resumable ones, then startable types — and then pi's own file rows, in the same popup: `@` stays the file picker it always was, and the handles are added to it rather than replacing it. Disable the whole thing via `/agents → Settings → Agent mentions`.
|
||||
|
||||
A `direct`-mode start takes the non-tool spawn path shared with the scheduler and cross-extension RPC, so — like those — it writes no `.output` transcript. That is the trade for skipping the model call: a `model`-mode start goes through the real `Agent` tool and keeps everything. Live tool activity and the turn counter are *not* part of that trade — a direct start renders them like any other agent. A mention-*resumed* agent goes through the full resume wiring and keeps both in either mode.
|
||||
|
||||
Individual agent results render Claude Code-style in the conversation:
|
||||
|
||||
| State | Example |
|
||||
|-------|---------|
|
||||
| **Running** | `⠹ ↻3≤30 · 3 tool uses · 12.4k token (8%)` / `⎿ searching, reading 3 files…` |
|
||||
| **Completed** | `✓ ↻8 · 5 tool uses · 33.8k token (62%) · 12.3s` / `⎿ Done` |
|
||||
| **Wrapped up** | `✓ ↻50≤50 · 50 tool uses · 89.1k token (84% · ⇊2) · 45.2s` / `⎿ Wrapped up (turn limit)` |
|
||||
| **Stopped** | `■ ↻3 · 3 tool uses · 12.4k token (8%)` / `⎿ Stopped` |
|
||||
| **Error** | `✗ ↻3 · 3 tool uses · 12.4k token (8%)` / `⎿ Error: timeout` |
|
||||
| **Aborted** | `✗ ↻55≤50 · 55 tool uses · 102.3k token (95% · ⇊3)` / `⎿ Aborted (max turns exceeded)` |
|
||||
|
||||
Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
|
||||
|
||||
By default, foreground and background agents each stream their full conversation to a per-subagent transcript — a JSON-lines file at `<os-tmpdir>/pi-subagents-<uid>/<cwd>/<session>/tasks/<agent-id>.output` (owner-only `0700`, cleared on reboot). Set `output_transcript: false` on a custom agent to write no transcript path or file for it, or set `outputTranscript: false` in `subagents.json` to make transcripts opt-in for the whole project (frontmatter overrides the project default). This governs **only** the transcript: it is independent of `persist_session` (the pi session on disk), and it does not affect `isolation: worktree` (which commits the agent's work to a git branch) or `memory:` (durable files) — set those accordingly if the goal is to keep a run off disk entirely. Background agent completion notifications render as styled boxes:
|
||||
|
||||
```
|
||||
✓ Find auth files completed
|
||||
↻3 · 3 tool uses · 12.4k token · 4.1s
|
||||
⎿ Found 5 files related to authentication...
|
||||
transcript: /tmp/pi-subagents-501/home-user-project/sess-1/tasks/agent-abc123.output
|
||||
```
|
||||
|
||||
Group completions render each agent as a separate block. The LLM receives structured `<task-notification>` XML for parsing, while the user sees the themed visual.
|
||||
|
||||
## Default Agent Types
|
||||
|
||||
| Type | Tools | Model | Prompt Mode | Description |
|
||||
|------|-------|-------|-------------|-------------|
|
||||
| `general-purpose` | all 7 | inherit | `append` (parent twin) | Inherits the parent's full system prompt — same rules, CLAUDE.md, project conventions |
|
||||
| `Explore` | read, bash, grep, find, ls | haiku (falls back to inherit) | `replace` (standalone) | Fast codebase exploration (read-only) |
|
||||
| `Plan` | read, bash, grep, find, ls | inherit | `replace` (standalone) | Software architect for implementation planning (read-only) |
|
||||
|
||||
The `general-purpose` agent is a **parent twin** — it receives the parent's entire system prompt plus a sub-agent context bridge, so it follows the same rules the parent does. Explore and Plan use standalone prompts tailored to their read-only roles.
|
||||
|
||||
Default agents can be **ejected** (`/agents` → select agent → Eject) to export them as `.md` files for customization, **overridden** by creating a `.md` file with the same name (e.g. `.pi/agents/general-purpose.md`), or **disabled** per-project with `enabled: false` frontmatter.
|
||||
|
||||
## Custom Agents
|
||||
|
||||
Define custom agent types by creating `.md` files. The frontmatter `name:` is the `subagent_type` and dispatch identity, falling back to the filename when absent; `display_name` only changes the UI label. Claiming a default agent's name overrides it.
|
||||
|
||||
Agents are discovered from three locations (higher priority wins):
|
||||
|
||||
| Priority | Location | Scope |
|
||||
|----------|----------|-------|
|
||||
| 1 (highest) | `.pi/agents/<name>.md` | Project — pi's config dir; authoritative, and where `/agents` writes |
|
||||
| 2 | `.agents/agents/<name>.md` | Project — the shared cross-tool `.agents` workspace (same convention as `.agents/skills/`) |
|
||||
| 3 | `$PI_CODING_AGENT_DIR/agents/<name>.md` (default `~/.pi/agent/agents/<name>.md`) | Global — available everywhere |
|
||||
|
||||
Project-level agents override global ones with the same name, so you can customize a global agent for a specific project. If both project locations define the same name, **`.pi/agents/` wins** — `.pi` stays the project authority; `.agents/agents/` is an additional read location for projects that keep their agent assets in the `.agents` workspace. The global location follows the upstream `PI_CODING_AGENT_DIR` env var — set it to relocate all pi-coding-agent state (agents, skills, settings) to a custom directory. An agent's name is its frontmatter `name:`, falling back to the filename, so two files can now claim the same one — the later load wins, and the warning below names the file that took over.
|
||||
|
||||
An unreadable or unparseable agent file is skipped, not fatal — a warning names the file and the error. If it was overriding a same-named agent, a second line names the file that loads instead. Set `strictAgentFiles: true` in `subagents.json` (or `/agents → Settings → Strict agent files`) to fail startup on a broken file instead; mid-session reloads still only warn.
|
||||
|
||||
### Example: `.pi/agents/auditor.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
color: red
|
||||
description: Security Code Reviewer
|
||||
tools: read, grep, find, bash
|
||||
model: anthropic/claude-opus-4-6
|
||||
thinking: high
|
||||
max_turns: 30
|
||||
---
|
||||
|
||||
You are a security auditor. Review code for vulnerabilities including:
|
||||
- Injection flaws (SQL, command, XSS)
|
||||
- Authentication and authorization issues
|
||||
- Sensitive data exposure
|
||||
- Insecure configurations
|
||||
|
||||
Report findings with file paths, line numbers, severity, and remediation advice.
|
||||
```
|
||||
|
||||
Then spawn it like any built-in type:
|
||||
|
||||
```
|
||||
Agent({ subagent_type: "auditor", prompt: "Review the auth module", description: "Security audit" })
|
||||
```
|
||||
|
||||
### Frontmatter Fields
|
||||
|
||||
All fields are optional — sensible defaults for everything.
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `description` | filename | Agent description shown in tool listings |
|
||||
| `name` | filename | **The agent's type** — what `subagent_type` and `@handle` address. Claude Code's rule: the filename doesn't have to match, so `blubb.md` with `name: code-review` dispatches as `code-review`. Omit it and the filename is used. Any value works except one containing `:`, which Claude Code reserves for plugin-scoped identifiers — such a file is skipped with a warning. Two files may declare the same name; the later load wins, as a filename clash always did |
|
||||
| `display_name` | the type | Label shown in the UI (widget, agent list, badges) — cosmetic only, and independent of `name`. Claude Code has no equivalent; a file that sets only `name` badges as its type, unchanged |
|
||||
| `color` | — | Background color for the agent name badge in the Agent tool header, widget, FleetView, and conversation viewer. Supports Claude Code's `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan` (the values its own default theme uses); quoted six-digit hex such as `"#8B5CF6"`; and Agency Agents aliases (`amber`, `teal`, `indigo`, `gold`, `neon-green`, `neon-cyan`, `metallic-blue`, `violet`, `rose`, `lime`, `gray`/`grey`, `fuchsia`, `slate`, `navy`). Badge text is black or white, whichever clears 4.5:1 against the rendered background — Claude Code uses one inverse color for every badge. Invalid values render no badge and preserve each surface's existing theme foreground |
|
||||
| `tools` | all 7 | Which tools the agent can call. Built-in names (`read, grep, …`), `*` / `all` (all built-ins), `none`, and `ext:<extension>` / `ext:<extension>/<tool>` selectors for extension tools. See [Tool & extension scoping](#tool--extension-scoping) below |
|
||||
| `extensions` | `true` | Which extensions to load for the agent. `true` (all defaults), `false` (none), or an explicit list: `[mcp, "/abs/path.ts", "*"]`. See [Tool & extension scoping](#tool--extension-scoping) below |
|
||||
| `exclude_extensions` | — | Extension denylist applied after `extensions:` — exclude wins. Plain names only (case-insensitive), no paths or `*`. Useful with `extensions: true` to drop one extension (e.g. `pi-notify`) |
|
||||
| `skills` | `true` | `true` inherits the parent's skills; `false` inherits none. A comma-separated list preloads **only** those skills into the system prompt and does not inherit the rest (see [Skill Preloading](#skill-preloading) for discovery locations) |
|
||||
| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
|
||||
| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
|
||||
| `isolation` | — | Set to `worktree` to run in an isolated git worktree, or `off` to refuse one even when the caller passes `isolation: "worktree"` (frontmatter is authoritative). `none`, `no`, and `false` are accepted spellings of `off` |
|
||||
| `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp are interchangeable) and falls back to the same model under another provider if the named one doesn't have it |
|
||||
| `thinking` | inherit | off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down |
|
||||
| `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
|
||||
| `persist_session` | `subagents.json` `rememberAgents` (default `true`) | Persist this subagent as a normal pi session instead of keeping the session in memory only; overrides the `rememberAgents` project default in both directions. It records its spawning session as parent, so it nests under it in `/resume`. The subagent's `.output` transcript is still written either way unless `output_transcript: false` |
|
||||
| `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write this subagent's `.output` transcript; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write no transcript file or path. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` |
|
||||
| `session_dir` | pi default | Optional session directory when `persist_session: true`; omitted uses pi's normal session location, and relative paths resolve from the agent cwd. A session outside the parent's session directory (this override, or `isolation: worktree`) is listed separately, so it shows as a root instead of nesting |
|
||||
| `allowed_subagents` | none | Opt in to scoped nested `Agent`, `get_subagent_result`, and `steer_subagent` tools. Omitted / empty / `none` / `false` = no nesting; `all` (or `"*"` / `true`) = any enabled agent; comma-separated list = only those agent types |
|
||||
| `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) |
|
||||
| `inherit_context` | `false` | Fork parent conversation into agent |
|
||||
| `run_in_background` | — | Pin this agent to background (`true`) or foreground (`false`). Omit to follow `backgroundByDefault` |
|
||||
| `isolated` | `false` | Hermetic specialist mode: forces optional `extensions: false` + `skills: false` + drops `ext:` selectors. Only built-in tools surface; host-mandatory infrastructure handlers may still bind. Distinct from `isolation: worktree` (filesystem) |
|
||||
| `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
|
||||
|
||||
Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified.
|
||||
|
||||
**Forgiving `model:` resolution.** A `model:` pin is matched against pi's model registry tolerantly, so cosmetic id variations don't silently drop the agent back to the parent's model: `.` and `-` are treated as equivalent in version numbers (`claude-haiku-4.5` ≡ `claude-haiku-4-5`), a trailing `-YYYYMMDD` date stamp is optional (`anthropic/claude-haiku-4-5-20251001` matches an undated registry id and vice-versa), and a `provider/modelId` whose named provider doesn't carry that model retries the bare id against every provider. Precedence is **exact → fuzzy under the named provider → same model under any provider → unavailable**, so an exact match always wins and dated snapshots aren't conflated. If nothing resolves, the pin can't run and the agent inherits the parent model — `/agents → Agent types` flags this case as `(unavailable, fallback: inherit)` and shows the resolved target `(→ provider/id)` when resolution lands on a different provider or version than configured. (This is distinct from [Model Scope](#model-scope) enforcement, which matches the `enabledModels` allowlist by *exact* entry.)
|
||||
|
||||
### Nested subagents
|
||||
|
||||
Nested delegation is default-off. Set `allowed_subagents` only on a non-isolated custom agent that owns a real fan-out responsibility:
|
||||
|
||||
```yaml
|
||||
---
|
||||
tools: read, grep, find
|
||||
extensions: false
|
||||
allowed_subagents: support-file-finder, support-callsite-tracer # or `all`
|
||||
---
|
||||
```
|
||||
|
||||
**The allowlist is a privilege boundary, not just a routing hint.** A child runs with *its own* `tools:`, `extensions:`, and `isolated:` — the parent's restrictions are not inherited — so delegation grants the parent the union of what the listed agents can do. The read-only agent above can write and run commands through any listed agent that can, and `all` reaches every enabled agent including `general-purpose`. Choose the list as carefully as you would choose `tools:` itself; that is the main reason this is default-off.
|
||||
|
||||
`allowed_subagents` is runtime-enforced. A comma-separated list restricts nesting to those types; `all` (or `"*"` / `true`, matching how `extensions:` and `skills:` take booleans) allows any enabled agent; omitted, empty, `none`, or `false` means no nested tools are injected at all. Unknown, disabled, and out-of-list types are rejected rather than falling back — regardless of the project's [fallback agent](#persistent-settings) setting, so a configured fallback can never hand a nested caller an agent outside its allowlist — and a nested `model:` is validated against [Model Scope](#model-scope) exactly like a top-level spawn. Result, resume, and steering operations are ownership-scoped, so a parent can control only its own children. Nested records remain internal to that parent and do not appear in top-level tools, lifecycle events, or agent UI — so when a parent finishes, is stopped, or ends a resumed turn, its nested children are stopped with it. They do write their own `.output` transcript (subject to the same `output_transcript` gate), filed under the root session's directory alongside their ancestors', so a nested run can still be inspected after the fact. Their token usage is folded into every ancestor's totals up to the top-level agent (lifecycle events, completion notifications, `/agents`), so nested spend stays attributable at any depth even though the children themselves stay hidden. A nested result that ends `stopped`, `aborted`, or `steered` is labelled as partial, the same guarantee top-level results carry.
|
||||
|
||||
The hard cap is depth 2 by default: main session (0) → subagent (1) → nested child (2). Change it project-wide with `maxSubagentDepth` in `subagents.json` (or `/agents → Settings → Nested depth`); `0` or `1` turns nesting off everywhere. An agent already at the cap gets no nested tools at all — not even `get_subagent_result`, since it can never own a child. A child must independently set `allowed_subagents` to delegate again; isolated agents never receive nested tools.
|
||||
|
||||
Nested children don't occupy `maxConcurrent` slots — their parent already holds one, and queueing them behind it would deadlock a parent waiting on its own child. The depth cap bounds how *deep* nesting goes, not how *wide*: a parent's only limit on concurrent children is that each spawn costs it a turn. Pair `allowed_subagents` with a `max_turns` on that agent if you want a hard ceiling on its fan-out.
|
||||
|
||||
Because a subagent session never activates this extension (that is what keeps a child from building a second agent manager, and it is why nested tools are injected directly instead), a subagent also gets none of the extension's other surfaces: no `/agents` command, no cross-extension RPC handlers, no `subagents:ready` event.
|
||||
|
||||
### Tool & extension scoping
|
||||
|
||||
`extensions:` decides **which optional extensions load**, while host-mandatory lifecycle entries remain bound; `tools:` decides **which tools surface to the LLM**. They compose:
|
||||
|
||||
```yaml
|
||||
# Default (both omitted): all extensions load, all 7 built-ins surface
|
||||
|
||||
tools: read, grep, find # narrow to listed built-ins; extensions still load
|
||||
tools: "*" # all 7 built-ins (alias: `all`)
|
||||
tools: none # zero built-ins (alias: `""`)
|
||||
tools: "*, ext:mcp/search" # built-ins plus one extension tool
|
||||
|
||||
extensions: false # no optional extensions; host-mandatory handlers still load
|
||||
extensions: [mcp] # only mcp loads
|
||||
extensions: ["*", "/abs/foo.ts"] # all defaults plus one path-loaded extension
|
||||
|
||||
exclude_extensions: pi-notify # everything except pi-notify (with extensions: true)
|
||||
|
||||
# Host policy is stronger than this frontmatter: exact mandatory extension
|
||||
# entries still bind, but their tools follow the normal policy above.
|
||||
|
||||
# Specialist: load one extension, expose only one of its tools, keep built-ins
|
||||
extensions: [mcp]
|
||||
tools: "*, ext:mcp/search"
|
||||
|
||||
isolated: true # built-ins only; no optional extension tools/skills/context
|
||||
```
|
||||
|
||||
A few rules the examples don't make obvious:
|
||||
|
||||
- `extensions:` is the sole loading authority. `ext:foo` in `tools:` narrows what surfaces; it can't load `foo` on its own. Mismatches fire `extension-error:…` warnings.
|
||||
- Any `ext:` entry flips extension tools to an explicit allowlist — unnamed extensions still load (handlers fire) but expose no tools. So `tools: "*, ext:mcp/search"` exposes only `search` from `mcp`, nothing from any other extension.
|
||||
- Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive.
|
||||
- Extensions that register tools **lazily** work too. MCP-backed extensions typically can't enumerate their tools until their servers connect, so they register from `session_start` or `before_agent_start` rather than at load. Subagent scoping is re-derived as tools appear, so these surface normally — including under `ext:` selectors, which keep narrowing correctly no matter when a tool shows up.
|
||||
- Extensions bound into a subagent see **both ends** of that session's lifecycle: `session_start` when the agent starts, `session_shutdown` (reason `quit`) when its session is disposed — on quit, and when its record is evicted ~10 minutes after it finishes. Release per-session resources there; anything left armed outlives the session it belongs to. Handlers are given three seconds on quit, after which teardown proceeds regardless.
|
||||
- An installed **package** extension matches by its package short name (`@scope/pi-subagents` → `[pi-subagents]`), in addition to its path-derived name (a package whose entry is `src/index.ts` also answers to `[src]`). Prefer the package name — the path-derived one is incidental.
|
||||
- Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent.
|
||||
- `exclude_extensions:` wins over `extensions:` and over `ext:` selectors — an excluded extension never loads and a `tools: ext:` entry can't pull it back. Plain names only (no paths, no `*`); a name matching nothing fires an `extension-error:…` warning.
|
||||
- `exclude_extensions:` is **not a sandbox**: excluded extensions' factory code still executes once during loading. Exclusion suppresses their tools and their bound lifecycle hooks (`pi.on` handlers like `session_start` only fire for extensions bound to the session), but not other load-time side effects — a factory that subscribes directly to the shared `pi.events` bus stays live. Don't rely on it to contain an untrusted extension.
|
||||
- Array and string forms are equivalent: `[a, b]` == `"a, b"`.
|
||||
|
||||
**How an agent's scope is advertised.** The Agent tool description lists every available agent with a `(Tools: …)` suffix, and that suffix is what the orchestrator reads when deciding where to route work. It describes **built-in scope only** — extension tools are resolved when the agent runs (extensions may register lazily, see above), so they can't be enumerated when the description is built:
|
||||
|
||||
| `tools:` | suffix |
|
||||
|---|---|
|
||||
| omitted, `*`, or `all` | `*` |
|
||||
| a list of built-ins | that list, e.g. `read, grep` |
|
||||
| `none` with `isolated: true` or `extensions: false` | `none` |
|
||||
| `none`, or only `ext:` entries, with extensions loading | `no built-ins, extension tools only` |
|
||||
|
||||
The last two rows are separate because zero built-ins is not zero tools: `tools: none` alongside `extensions:` still surfaces every extension tool, so calling it `none` would understate what the agent can do. Note `*` doesn't enumerate extension tools either — an agent with `tools: "*, ext:mcp/search"` advertises `*`.
|
||||
|
||||
## Tools
|
||||
|
||||
### `Agent`
|
||||
|
||||
Launch a sub-agent.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `prompt` | string | yes | The task for the agent |
|
||||
| `description` | string | yes | Short 3-5 word summary (shown in UI) |
|
||||
| `name` | string | no | Memorable name for this agent (`auth-audit`), addressable as `@name` and accepted by `steer_subagent`/`get_subagent_result`. Additive — the type-derived handle is still assigned |
|
||||
| `subagent_type` | string | yes | Agent type (built-in or custom) |
|
||||
| `model` | string | no | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp interchangeable) with provider fallback |
|
||||
| `thinking` | string | no | Thinking level: off, minimal, low, medium, high, xhigh, max (availability depends on pi version and model) |
|
||||
| `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
|
||||
| `run_in_background` | boolean | no | Defaults to `true`; `false` blocks and returns the result inline |
|
||||
| `resume` | string | no | Agent ID to resume a previous session |
|
||||
| `isolated` | boolean | no | No extension/MCP tools |
|
||||
| `isolation` | `"off"` \| `"worktree"` | no | `worktree` runs in an isolated git worktree; `off` (the default) does not. Absent from the schema entirely when `worktreeIsolation: false` |
|
||||
| `inherit_context` | boolean | no | Fork parent conversation into agent |
|
||||
|
||||
### `get_subagent_result`
|
||||
|
||||
Check status and retrieve results from a background agent.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `agent_id` | string | yes | Agent ID to check |
|
||||
| `wait` | boolean | no | Wait for completion |
|
||||
| `verbose` | boolean | no | Include full conversation log |
|
||||
|
||||
Cancelling a `wait: true` call (for example, with `Esc`) stops only the wait. The background agent keeps running, and its completion notification still arrives normally.
|
||||
|
||||
### `steer_subagent`
|
||||
|
||||
Send a steering message to a running agent. The message interrupts after the current tool execution.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `agent_id` | string | yes | Agent ID to steer |
|
||||
| `message` | string | yes | Message to inject into agent conversation |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/agents` | Interactive agent management menu |
|
||||
|
||||
The `/agents` command opens an interactive menu:
|
||||
|
||||
```
|
||||
Running agents (2) — 1 running, 1 done ← only shown when agents exist
|
||||
Agent types (6) ← unified list: defaults + custom
|
||||
Create new agent ← manual wizard or AI-generated
|
||||
Settings ← max concurrency, max turns, grace turns, join mode
|
||||
```
|
||||
|
||||
- **Running agents** — select one to open its live conversation viewer. While it's still running, press `Enter` to open the steering composer, then `Enter` again to send a message that redirects the agent (same mechanism as the `steer_subagent` tool; `Esc` or an empty submit returns), or press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion.
|
||||
- **Agent types** — unified list with source indicators: `•` (project), `◦` (global), `✕` (disabled). Each row shows the agent's model, and the highlighted agent's full description appears below the list. The model column flags `(unavailable, fallback: inherit)` when a configured model can't be resolved (it would silently inherit the parent model), and shows `(→ provider/id)` when it resolves to a different provider or version than configured. Select an agent to manage it:
|
||||
- **Default agents** (no override): Eject (export as `.md`), Disable
|
||||
- **Default agents** (ejected/overridden): Edit, Disable, Reset to default, Delete
|
||||
- **Custom agents**: Edit, Disable, Delete
|
||||
- **Disabled agents**: Enable, Edit, Delete
|
||||
- **Eject** — writes the embedded default config as a `.md` file to project or personal location, so you can customize it
|
||||
- **Disable/Enable** — toggle agent availability. Disabled agents stay visible in the list (marked `✕`) and can be re-enabled
|
||||
- **Create new agent** — choose project/personal location, then manual wizard (step-by-step prompts for name, tools, model, thinking, system prompt) or AI-generated (describe what the agent should do and a sub-agent writes the `.md` file). Any name is allowed, including default agent names (overrides them)
|
||||
- **Settings** — configure max concurrency, default max turns, grace turns, and join mode at runtime
|
||||
|
||||
## Graceful Max Turns
|
||||
|
||||
Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
|
||||
|
||||
1. At `max_turns` — steering message: *"Wrap up immediately — provide your final answer now."*
|
||||
2. Up to 5 grace turns to finish cleanly
|
||||
3. Hard abort only after the grace period
|
||||
|
||||
| Status | Meaning | Icon |
|
||||
|--------|---------|------|
|
||||
| `completed` | Finished naturally | `✓` green |
|
||||
| `steered` | Hit limit, wrapped up in time | `✓` yellow |
|
||||
| `aborted` | Grace period exceeded | `✗` red |
|
||||
| `stopped` | User-initiated abort | `■` dim |
|
||||
|
||||
## Concurrency
|
||||
|
||||
Background agents are subject to a configurable concurrency limit (default: 10). Excess agents are automatically queued and start as running agents complete. The widget shows queued agents as a collapsed count.
|
||||
|
||||
Foreground agents bypass the queue — they block the parent anyway. Since agents run in the background by default, nearly every spawn now takes a slot; the limit was raised from 4 so that ordinary parallel fan-outs don't queue.
|
||||
|
||||
## Join Strategies
|
||||
|
||||
When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered. It applies only to background agents.
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `smart` (default) | 2+ background agents spawned in the same turn are auto-grouped into a single consolidated notification. Solo agents notify individually. |
|
||||
| `async` | Each agent sends its own notification on completion (original behavior). Best when results need incremental processing. |
|
||||
| `group` | Force grouping even when spawning a single agent. Useful when you know more agents will follow. |
|
||||
|
||||
**Timeout behavior:** When agents are grouped, a 30-second timeout starts after the first agent completes. If not all agents finish in time, a partial notification is sent with completed results and remaining agents continue with a shorter 15-second re-batch window for stragglers.
|
||||
|
||||
**Configuration:**
|
||||
- Configure join mode in `/agents` → Settings → Join mode
|
||||
|
||||
## Model Scope
|
||||
|
||||
**Opt-in:** off by default. Enable via `/agents → Settings → Scope models`.
|
||||
|
||||
When on, each subagent spawn's effective model is validated against pi's own `enabledModels` list (configured via pi's `/scoped-models` UI). pi-subagents reads that list; it doesn't manage it. Both of pi's settings files are honored: global `~/.pi/agent/settings.json` and project-local `<cwd>/.pi/settings.json`. **Project overrides global** — mirrors pi's `SettingsManager` deep-merge, so a tighter per-project scope (hand-edited into the project settings) is respected.
|
||||
|
||||
**Out-of-scope handling depends on source:**
|
||||
|
||||
| Model source | Out-of-scope behavior |
|
||||
|---|---|
|
||||
| Caller-supplied via `Agent({ model: "..." })` | Hard error returned to the orchestrator, listing allowed models |
|
||||
| Pinned in agent frontmatter | Warning toast + the pinned model runs (frontmatter is authoritative) |
|
||||
| Parent-inherited (neither set) | Warning toast + parent's model runs |
|
||||
|
||||
**Design:** `scopeModels` is a guardrail against the orchestrator picking unexpected models at runtime, not a hard policy against user-level config. The "frontmatter is authoritative" guarantee from v0.5.1 still holds for `model:` — caller params can't override frontmatter, and frontmatter pins run even when out of scope (with a visible warning).
|
||||
|
||||
**Nested spawns** ([nested subagents](#nested-subagents)) apply the same table against the parent's config root. The hard-error case is identical; the warning cases proceed silently, since a subagent session has no UI to toast to.
|
||||
|
||||
**Pattern format:** only exact `provider/modelId` entries are honored (e.g. `anthropic/claude-haiku-4-5-20251001`). Glob patterns (`*sonnet*`), bare model IDs, and `:thinking` suffixes — which pi itself supports — are silently dropped here. pi's `/scoped-models` picker writes exact entries, so the limitation is invisible if you configure scope through the UI. Hand-edited globs produce an empty allowed set (scope check becomes a no-op).
|
||||
|
||||
**No-op safety:** if `enabledModels` is missing or empty in pi's settings, scope check skips entirely — no false positives, no spurious errors.
|
||||
|
||||
## Persistent Settings
|
||||
|
||||
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, nested depth, fallback agent, default join mode, scheduling on/off, scope models on/off, disable defaults on/off, strict agent files on/off, agent mentions on/off, output transcript on/off, tool description full/compact/custom, widget all/background/off, usage reporting on/off, cost display on/off) persist across pi restarts. Two files, merged on load:
|
||||
|
||||
- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
|
||||
- **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
|
||||
|
||||
**Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `10`, default max turns unlimited, grace turns `5`, nested depth `2`, join mode `smart`, defaults enabled).
|
||||
|
||||
**Nested depth** (`maxSubagentDepth`, default `2`): the hard ceiling on [nested delegation](#nested-subagents), counted from the main session (main = 0, its subagents = 1). `0` or `1` disables nesting project-wide regardless of any agent's `allowed_subagents`. Read when a subagent session is built, so a change applies to agents started after it.
|
||||
|
||||
**Fallback agent** (`fallbackSubagent`, default `general-purpose`): the agent used when a caller-supplied `subagent_type` doesn't resolve to exactly one enabled agent — unknown, disabled, or ambiguous because two agents differ only by case. Name any enabled agent to route those calls there instead, or set `none` for **strict**, fail-closed dispatch: the call is refused with an error listing the available types, and nothing spawns. Strict mode matters most for background and scheduled calls, which would otherwise start executing a substituted agent before the caller learns anything. Also settable from `/agents → Settings → Fallback agent`. The boolean `false` is accepted as a spelling of `none`, because it would otherwise be dropped as the wrong type and silently leave the permissive default in place. Every other value is read as an agent name, so a mistaken `off` fails loudly at dispatch rather than meaning one thing in the settings file and another in the resolver. A fallback agent that is itself unknown or disabled is a misconfiguration and is reported rather than quietly replaced. Note the default is unchanged and stays permissive by design: with `disableDefaultAgents` and no `general-purpose` of your own, an unresolvable type still resolves to a built-in config carrying *all* tools — set `none` (or name one of your own agents) to close that.
|
||||
|
||||
**Strict agent files** (`strictAgentFiles`, default `false`): when on, an unreadable or unparseable [agent file](#custom-agents) aborts extension load at startup and names the file, instead of being skipped with a warning — so a checked-in `.pi/agents/` can't silently fall through to a same-named agent from another location. Startup only: the mid-session reload that runs on each `Agent` call keeps warning either way, since a bad edit shouldn't kill a session on an unrelated spawn. Also settable from `/agents → Settings → Strict agent files`.
|
||||
|
||||
**Disable defaults** (`disableDefaultAgents`, default `false`): when on, the three built-in agents (general-purpose, Explore, Plan) are not registered — only your project/global custom agents are advertised and spawnable. User-defined agents are unaffected, including ones that override a default by name. The Agent tool's type list updates on the next pi session (the tool schema is registered at startup).
|
||||
|
||||
**Agent mentions** (`agentMentions`, default `"model"`): whether [`@handle message`](#agent-mentions) at the prompt addresses that subagent instead of the main model — messaging, resuming or starting it — and whether `@` offers agents alongside pi's file completion. `"model"` and `"direct"` differ only in [who starts an agent that isn't running](#starting-a-new-agent): an off-screen clone of this conversation, via a `<system-reminder>` and a real `Agent` call, or this extension, immediately and with no model call. Messaging and resuming are direct in both. `"off"` gates all three actions plus the suggestion list, so `@` means only "attach a file" again and every `@…` prompt reaches the main model verbatim. Toggle via `/agents → Settings → Agent mentions`; applied live. The booleans this setting used to take are still read — `true` as `"model"`, `false` as `"off"`.
|
||||
|
||||
**Background by default** (`backgroundByDefault`, default `true`): what an `Agent` call that doesn't say means. On — following Claude Code — the agent runs detached, the call returns its ID immediately, and a completion notification carries a preview of the result (`get_subagent_result` for the full text). Set `false` to restore the previous behaviour, where an unqualified spawn blocked the turn and returned its output inline. An explicit `run_in_background` on the call, or in an agent file's frontmatter, overrides this in both directions; the setting only decides what "unspecified" means. **Top-level only** — a nested spawn (an agent spawning its own) always defaults to foreground, because a detached child is stopped when its parent settles and has no notification path of its own. Toggle via `/agents → Settings → Background by default`; applied live.
|
||||
|
||||
**Remember agents** (`rememberAgents`, default `true`): whether subagents persist their pi session, which is what lets [`@handle`](#agent-mentions) reopen an agent's conversation after its in-memory record has been evicted. Two visible consequences of the default: top-level subagents write a session file, and they nest under the session that spawned them in pi's `/resume`. Agents spawned by another agent are excluded — they get no handle, so nothing could reopen their transcript. A custom agent's `persist_session` frontmatter overrides this per agent, in both directions. Toggle via `/agents → Settings → Remember agents`; with it off, handles expire with their record (roughly ten minutes past completion) and `@explore` then starts a fresh agent rather than resuming — the behaviour before this setting existed.
|
||||
|
||||
**Output transcript** (`outputTranscript`, default `true`): the project/global default for writing each subagent's `.output` transcript. Toggle via `/agents → Settings → Output transcript`, or set `false` in `subagents.json` to make transcripts opt-in project-wide — useful when run transcripts shouldn't sit on disk for backup or DLP tooling to pick up. A custom agent's `output_transcript` frontmatter overrides this per agent. Applied live at spawn time. Governs only the transcript, not `persist_session`, worktree commits, or memory files.
|
||||
|
||||
**Worktree isolation** (`worktreeIsolation`, default `true`): whether `isolation: "worktree"` may create a worktree at all. Toggle via `/agents → Settings → Worktree isolation`, or set `false` in `subagents.json` on a repo where a copy costs too much time or disk. Off, the `Agent` tool's `isolation` parameter is dropped from the schema entirely and the bullet describing it leaves the tool description with it — nothing to pass, and no context spent describing it — and worktrees are refused on every other path too (agent files, scheduled jobs, cross-extension RPC). The `/agents` agent-file generator stops offering the `isolation:` frontmatter field too, so a generated agent can't bake in a request that would be refused. A requested worktree is downgraded to a normal run rather than failing the call, since declining one is the point; there is deliberately no note on the result, which is exactly why the prose has to go when the parameter does. The refusal applies immediately; the parameter and its prose appear or disappear on the next pi session. See [Turning worktrees off](#turning-worktrees-off).
|
||||
|
||||
**Report usage to session** (`reportUsage`, default `false`): whether subagent spend is added to *this* session's own totals. Subagents run in their own pi sessions, so by default pi's footer, statusline and `/cost` count only what the main model spent — a session that delegated most of its work reads as nearly free. Turn it on and each `Agent` / `get_subagent_result` / `steer_subagent` result carries the spend accumulated since the last one, which pi folds into `getSessionStats()`; `/cost` attributes it to the **Tools/summaries** bucket. Toggle via `/agents → Settings → Report usage to session`; applied live.
|
||||
|
||||
Three things worth knowing about the numbers. Every token component is reported, `cacheRead` included — the cached prefix genuinely is re-read and re-billed on every call, and pi counts it the same way for the session's own messages, so withholding it would make a subagent's rows count differently from every other row in one total. (The extension's *own* token displays still leave it out, which is a different question: there it inflates a reading of how much work was done.) Cost is pi's own per-message figure, priced from the model's listed rates; a model pi has no rates for contributes zero rather than an estimate. And the context-window percentage is untouched: pi derives it from assistant messages alone, so a delegating session's context doesn't appear to fill up faster. Agents that finish in the background have no tool result of their own to ride on, so their spend is carried by the next one you make — the footer catches up on the following call, not the moment they finish.
|
||||
|
||||
**Show cost** (`showCost`, default `false`): whether the subagent surfaces print an estimated cost beside their token counts — the widget (running *and* finished lines), [FleetView](#fleetview), the conversation viewer, foreground results, `get_subagent_result`, and completion notifications:
|
||||
|
||||
```text
|
||||
├─ ⠹ Explore inspect code · ↻3 · 8.2k token · ~$0.0042 · 4.1s
|
||||
✓ Explore inspect code · ↻8 · 5 tool uses · ~$0.0181 · 12.3s
|
||||
```
|
||||
|
||||
When several background agents finish together, their notification is topped with the batch total (`3 agents · 45.1k token · ~$0.042`) so the figures don't have to be added up by hand.
|
||||
|
||||
The `~` marks it as pi's estimate rather than a billed figure. **A cost is shown only when there is one to show:** a model pi has no pricing data for reports zero, and `$0.00` beside its tokens would say the run was measured and found free rather than never measured — so nothing is printed at all, on every surface. For the same reason a real cost too small to render reads `<$0.0001`. Figures keep cents at minimum and four decimals at most (`~$0.0042`, `~$0.05`, `~$1.24`) — rounding everything to cents would print the same number for runs that differed fourfold.
|
||||
|
||||
Independent of `reportUsage`: this one is what you read, that one is what your session counts. Toggle via `/agents → Settings → Show cost`; applied live.
|
||||
|
||||
**Tool description** (`toolDescriptionMode`, default `"full"`): which Agent tool description the LLM sees. `"full"` is the rich Claude Code-style prompt (~1,400 tokens with the default agents); `"compact"` is ~75% smaller — one-line agent type list, terse usage notes — for small/local models where tool-spec tokens are expensive. Per-option details stay in the parameter descriptions in every mode (the parameter schema is never customizable). Applies on the next pi session.
|
||||
|
||||
`"custom"` registers your own description from `<cwd>/.pi/agent-tool-description.md` (project) or `<agentDir>/agent-tool-description.md` (global; project wins). The file is read once at tool registration, so edits also apply on the next pi session. Dynamic parts stay live via placeholders — a static agent list would go stale the moment you add a custom agent:
|
||||
|
||||
```markdown
|
||||
Launch an autonomous agent. Available types:
|
||||
{{typeList}}
|
||||
|
||||
Custom agents live in .pi/agents/ or {{agentDir}}/agents/.
|
||||
```
|
||||
|
||||
Placeholders: `{{typeList}}` (full per-agent descriptions), `{{compactTypeList}}` (first sentence each), `{{agentDir}}`, `{{isolationGuideline}}` and `{{scheduleGuideline}}` (each expands with its own leading newline + `- ` bullet when the matching feature is on — place them directly after your last rule line; empty when [worktree isolation](#turning-worktrees-off) / scheduling is off). Unknown placeholders are left verbatim with a stderr warning; a missing or empty file falls back to `"full"` with a warning. Note the usual trust umbrella: a project-level file shapes the orchestrator's prompt, same as project agents and extensions do.
|
||||
|
||||
**Starting point:** copy [`examples/agent-tool-description.md`](examples/agent-tool-description.md) — it reproduces the default full description exactly (a CI test keeps it in sync), so you can trim from a known-good baseline instead of writing from scratch.
|
||||
|
||||
**Example — global defaults for a beefy machine:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.pi/agent
|
||||
cat > ~/.pi/agent/subagents.json <<'EOF'
|
||||
{
|
||||
"maxConcurrent": 16,
|
||||
"graceTurns": 10
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Every project now starts with concurrency 16 and grace 10, without ever touching the menu. Individual projects can still override via `/agents` → Settings.
|
||||
|
||||
**Failure behavior:** missing file is silent; malformed JSON logs a `[pi-subagents] Ignoring malformed settings at …` warning to stderr; invalid/out-of-range field values are dropped per-field; write failures downgrade the `/agents` toast to a warning with `(session only; failed to persist)`.
|
||||
|
||||
## Events
|
||||
|
||||
Agent lifecycle events are emitted via `pi.events.emit()` so other extensions can react:
|
||||
|
||||
| Event | When | Key fields |
|
||||
|-------|------|------------|
|
||||
| `subagents:created` | Background agent registered | `id`, `type`, `description`, `isBackground` |
|
||||
| `subagents:started` | Agent transitions to running (including queued→running) | `id`, `type`, `description` |
|
||||
| `subagents:completed` | Agent finished successfully (background and foreground) | `id`, `type`, `durationMs`, `tokens` (display total, `{ input, output, total }` — see the note below), `usage` (the run's spend as a pi `Usage`: token components including `cacheRead`, plus `cost.total` in USD; absent when nothing was spent), `toolUses`, `result` |
|
||||
| `subagents:failed` | Agent errored, stopped, or aborted (background and foreground) | same as completed + `error`, `status` |
|
||||
| `subagents:steered` | Steering message sent | `id`, `message` |
|
||||
| `subagents:compacted` | Agent's session successfully compacted | `id`, `type`, `description`, `reason` (`"manual"` / `"threshold"` / `"overflow"`), `tokensBefore`, `compactionCount` |
|
||||
| `subagents:scheduled` | Schedule lifecycle change | `{ type: "added" \| "removed" \| "updated" \| "fired" \| "error", … }` (job/agentId/error fields per type) |
|
||||
| `subagents:scheduler_ready` | Scheduler bound to session, enabled jobs armed | `sessionId`, `jobCount` |
|
||||
| `subagents:ready` | RPC handlers registered and armed — fired on session start; not emitted in a session that excludes pi-subagents | — |
|
||||
| `subagents:settings_loaded` | Persisted settings applied at extension init | `settings` (merged global + project) |
|
||||
| `subagents:settings_changed` | `/agents` → Settings mutation was applied | `settings`, `persisted` (`boolean` — `false` on write failure) |
|
||||
|
||||
`tokens.total` = `input + output + cacheWrite`. `cacheRead` is excluded — each turn's `cacheRead` is the cumulative cached prefix re-read on that one API call, so summing per-message would over-count it as a measure of work done. Use `contextUsage.percent` (surfaced as `(NN%)` in the widget) for current context size.
|
||||
|
||||
`usage` answers the other question — what was billed — and so does include `cacheRead`, because the prefix really is re-read and re-charged on every call. It is a pi `Usage`, the same shape pi puts on `ToolResultEvent` and `AssistantMessage`, so `usage.cost.total` is where a listener already expects the money and anything pi adds to `Usage` arrives without a change here. Neither field derives from the other; `tokens` is a view model, `usage` is the data.
|
||||
|
||||
## Cross-Extension RPC
|
||||
|
||||
Other pi extensions can spawn and stop subagents programmatically via the `pi.events` event bus, without importing this package directly.
|
||||
|
||||
All RPC replies use a standardized envelope: `{ success: true, data?: T }` on success, `{ success: false, error: string }` on failure.
|
||||
|
||||
### Discovery
|
||||
|
||||
Listen for `subagents:ready` to know when RPC handlers are available:
|
||||
|
||||
```typescript
|
||||
pi.events.on("subagents:ready", () => {
|
||||
// RPC handlers are registered — safe to call ping/spawn/stop
|
||||
});
|
||||
```
|
||||
|
||||
`subagents:ready` fires only when pi-subagents is actually loaded **and bound** in the current session. A session that excludes it (via an agent's `extensions:`) emits no `subagents:ready` and does not answer the RPC channels — exactly as if pi-subagents were not installed. Treat "no `subagents:ready`" as "not available here" and give discovery a timeout rather than waiting indefinitely.
|
||||
|
||||
### Ping
|
||||
|
||||
Check if the subagents extension is loaded and get the protocol version:
|
||||
|
||||
```typescript
|
||||
const requestId = crypto.randomUUID();
|
||||
const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (reply) => {
|
||||
unsub();
|
||||
if (reply.success) console.log("Protocol version:", reply.data.version);
|
||||
});
|
||||
pi.events.emit("subagents:rpc:ping", { requestId });
|
||||
```
|
||||
|
||||
### Spawn
|
||||
|
||||
Spawn a subagent and receive its ID:
|
||||
|
||||
```typescript
|
||||
const requestId = crypto.randomUUID();
|
||||
const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (reply) => {
|
||||
unsub();
|
||||
if (!reply.success) {
|
||||
console.error("Spawn failed:", reply.error);
|
||||
} else {
|
||||
console.log("Agent ID:", reply.data.id);
|
||||
}
|
||||
});
|
||||
pi.events.emit("subagents:rpc:spawn", {
|
||||
requestId,
|
||||
type: "general-purpose",
|
||||
prompt: "Do something useful",
|
||||
options: { description: "My task", isBackground: true },
|
||||
});
|
||||
```
|
||||
|
||||
`options` is the manager's spawn-option object, not the `Agent` tool's parameter schema — the background flag is `isBackground`, and the tool's snake_case `run_in_background` is forwarded verbatim and ignored. Every RPC spawn returns its id immediately and runs detached either way; `isBackground: true` is what makes the agent occupy one of the `maxConcurrent` slots (and queue behind them when they are full) and what `subagents:created` reports. Leaving it unset starts the agent immediately regardless of the limit. A top-level RPC spawn renders in the widget and FleetView while it runs, with the same live tool activity and turn counter an `Agent`-tool spawn gets — only an explicit `isBackground: false` is dropped by the widget's default `background` mode, the way a foreground `Agent` call is. Nested spawns stay hidden from both.
|
||||
|
||||
`options.model` accepts either a `Model` object (e.g. `ctx.model`) or a `"provider/modelId"` string — strings are resolved against `ctx.modelRegistry` at the RPC boundary, so cross-extension callers can forward serializable values without losing auth context.
|
||||
|
||||
`options.cwd` (absolute path to an existing directory — anything else returns an error envelope; `null` means unset) runs the agent in a different working directory than the parent session. Its tools operate there and the prompt's environment block describes it, but **`.pi` config still loads from the parent session's project** — the target directory's `.pi` extensions never execute, and its agents/skills/settings are not picked up. Combined with `isolation: "worktree"`, the worktree is created *from* the target directory's repo, the agent works at the equivalent subdirectory inside the copy (a monorepo-package cwd stays scoped to that package), and the resulting `pi-agent-*` branch lands in that repo — the completion message names it. On session end, worktree registrations are pruned in every repo that received one; only a hard crash can leave a stale entry (then: `git worktree prune` in the target repo). Agents with `memory:` keep reading/writing the parent project's memory.
|
||||
|
||||
### Stop
|
||||
|
||||
Stop a running agent by ID:
|
||||
|
||||
```typescript
|
||||
const requestId = crypto.randomUUID();
|
||||
const unsub = pi.events.on(`subagents:rpc:stop:reply:${requestId}`, (reply) => {
|
||||
unsub();
|
||||
if (!reply.success) console.error("Stop failed:", reply.error);
|
||||
});
|
||||
pi.events.emit("subagents:rpc:stop", { requestId, agentId: "agent-id-here" });
|
||||
```
|
||||
|
||||
Reply channels are scoped per `requestId`, so concurrent requests don't interfere.
|
||||
|
||||
## Persistent Agent Memory
|
||||
|
||||
Agents can have persistent memory across sessions. Set `memory` in frontmatter to enable:
|
||||
|
||||
```yaml
|
||||
---
|
||||
memory: project # project | local | user
|
||||
---
|
||||
```
|
||||
|
||||
| Scope | Location | Use case |
|
||||
|-------|----------|----------|
|
||||
| `project` | `.pi/agent-memory/<name>/` | Shared across the team (committed) |
|
||||
| `local` | `.pi/agent-memory-local/<name>/` | Machine-specific (gitignored) |
|
||||
| `user` | `<agentDir>/agent-memory/<name>/` (default `~/.pi/agent/agent-memory/`, honors `PI_CODING_AGENT_DIR`) | Global personal memory |
|
||||
|
||||
The `user` scope previously hardcoded `~/.pi/agent-memory/`. If that legacy directory exists for an agent and the new location doesn't, it keeps being used — existing memories aren't orphaned.
|
||||
|
||||
Memory uses a `MEMORY.md` index file and individual memory files with frontmatter. Agents with write tools get full read-write access. **Read-only agents** (no `write`/`edit` tools) automatically get read-only memory — they can consume memories written by other agents but cannot modify them. This prevents unintended tool escalation.
|
||||
|
||||
The `disallowed_tools` field is respected when determining write capability — an agent with `tools: write` + `disallowed_tools: write` correctly gets read-only memory.
|
||||
|
||||
## Worktree Isolation
|
||||
|
||||
Set `isolation: worktree` to run an agent in a temporary git worktree:
|
||||
|
||||
```
|
||||
Agent({ subagent_type: "refactor", prompt: "...", isolation: "worktree" })
|
||||
```
|
||||
|
||||
The agent gets a full, isolated copy of the repository. The worktree directory is removed on completion either way — what differs is whether a branch is left behind:
|
||||
- **No changes:** worktree is cleaned up automatically, no branch
|
||||
- **Changes made:** changes are committed to a new branch (`pi-agent-<id>`), and the result names the branch and the `git merge` command for it. The branch is the only artifact — the worktree path is gone, so nothing points into it
|
||||
- **Agent committed its own work:** the branch is created at the agent's HEAD, preserving its commits (uncommitted leftovers are committed on top first)
|
||||
|
||||
The agent's system prompt names the worktree as an isolated copy and tells it to work only there, even if other instructions name the main checkout — otherwise an inherited parent prompt or a task prompt mentioning the project path walks it straight back out of the copy. This is a directive, not a sandbox: an agent with shell access can still `cd` out, so don't rely on `isolation` alone to protect the main checkout.
|
||||
|
||||
The automatic preservation commit uses `--no-verify`, so local pre-commit hooks can't block it — the commit is local-only and never pushed, and pre-push/server-side hooks still apply.
|
||||
|
||||
If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` call fails with a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. The call is reported as a failed tool call, not as a subagent that ran and returned that message, so the model doesn't retry it as if the agent had merely reported a problem. Initialize git and commit at least once, or omit `isolation`.
|
||||
|
||||
A worktree is a *copy*, so the agent cannot see uncommitted or staged changes in the main checkout. Never use it to review a working-tree or staged diff: the agent finds an empty `git diff` and reports nothing wrong.
|
||||
|
||||
### Turning worktrees off
|
||||
|
||||
Three levers, from narrowest to broadest:
|
||||
|
||||
- **Per call** — omit `isolation`, or pass `isolation: "off"`. The explicit value exists because some models fill every optional parameter they are offered; with `worktree` as the only legal value they had no way to decline one (#231, #184).
|
||||
- **Per agent** — `isolation: off` in an agent file. Frontmatter is authoritative, so this refuses a worktree even when the caller passes `isolation: "worktree"` — the only way to override a caller.
|
||||
- **Per project** — `"worktreeIsolation": false` in `subagents.json`. The `Agent` tool's `isolation` parameter disappears from the schema entirely, along with the usage-note bullet that describes it (so it costs the model no context and cannot be passed), and worktree creation is refused on every other path too: agent files, scheduled jobs, and cross-extension RPC. The `/agents` generator also stops offering `isolation:` when writing a new agent file. Use it on a repo large enough that a copy costs real time and disk. The schema and the description are both built at tool registration, so they appear or disappear in the next pi session; the refusal itself takes effect immediately.
|
||||
|
||||
Schema and prose are gated together on purpose. Leaving the bullet in would teach the model to pass a field that is no longer declared — accepted silently, then dropped — and since a refused worktree carries no note on the result, the model would have every reason to go on reporting a `pi-agent-*` branch that was never created. A custom tool description should use the `{{isolationGuideline}}` placeholder rather than hardcoding the bullet, for the same reason.
|
||||
|
||||
## Skill Preloading
|
||||
|
||||
Skills can be preloaded by name and injected into the agent's system prompt:
|
||||
|
||||
```yaml
|
||||
---
|
||||
skills: api-conventions, error-handling
|
||||
---
|
||||
```
|
||||
|
||||
**Discovery roots** (checked in this order, first match wins):
|
||||
|
||||
| Scope | Path | Source |
|
||||
|---|---|---|
|
||||
| Project | `<cwd>/.pi/skills/` | Pi-standard |
|
||||
| Project | `<cwd>/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
|
||||
| User | `$PI_CODING_AGENT_DIR/skills/` (default `~/.pi/agent/skills/`) | Pi-standard |
|
||||
| User | `~/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
|
||||
| User | `~/.pi/skills/` | Legacy (pre-Pi) |
|
||||
|
||||
**Per root, a skill named `foo` resolves to the first of:**
|
||||
|
||||
- `<root>/foo.md` — flat file at the top level
|
||||
- `<root>/foo/SKILL.md` — directory skill (top-level)
|
||||
- `<root>/*/.../foo/SKILL.md` — directory skill, found by recursive descent
|
||||
|
||||
Recursion skips dotfile directories and `node_modules`. A directory that itself contains a `SKILL.md` is treated as a single skill — we don't descend into it. Traversal is byte-order sorted for deterministic resolution across filesystems.
|
||||
|
||||
**Security:** symlinks are rejected at every layer (root, flat file, skill directory, `SKILL.md` inside a skill directory) — intentional deviation from Pi, which follows symlinks. Skill names with path-traversal characters (`..`, `/`, `\`, spaces, leading dot, >128 chars) are rejected.
|
||||
|
||||
## Tool Denylist
|
||||
|
||||
Block specific tools from an agent even if extensions provide them:
|
||||
|
||||
```yaml
|
||||
---
|
||||
tools: read, bash, grep, write
|
||||
disallowed_tools: write, edit
|
||||
---
|
||||
```
|
||||
|
||||
This is useful for creating agents that inherit extension tools but should not have write access.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # Extension entry: tool/command registration, /agents menu, rendering
|
||||
types.ts # Type definitions (AgentConfig, AgentRecord, etc.)
|
||||
|
||||
# Agent registry
|
||||
default-agents.ts # Embedded default agent configs (general-purpose, Explore, Plan)
|
||||
custom-agents.ts # Load user-defined agents from .pi/agents/, .agents/agents/, and global agents
|
||||
agent-types.ts # Unified agent registry (defaults + user), tool name resolution
|
||||
agent-file-toggle.ts # Locate/edit an agent's .md: enabled: toggle, eject to frontmatter
|
||||
agent-color.ts # Claude Code/Agency Agents name color parsing and badge rendering
|
||||
|
||||
# Execution
|
||||
agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume
|
||||
agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
|
||||
nested-tools.ts # Delegation tools handed to subagents (nested spawn/collect/steer)
|
||||
child-context.ts # AsyncLocalStorage flag marking work done for a child session
|
||||
abortable.ts # Race a wait against Esc without cancelling the background child
|
||||
group-join.ts # Group join manager: batched completion notifications with timeout
|
||||
status-note.ts # Honest status note + salvaged partial output for non-normal outcomes
|
||||
usage.ts # Token usage shapes, accumulators, session-stats readers
|
||||
|
||||
# Invocation surface
|
||||
invocation-config.ts # Shared tool-parameter schemas (isolation, join, thinking, ...)
|
||||
model-resolver.ts # Model resolution: exact provider/modelId with fuzzy fallback
|
||||
enabled-models.ts # Read pi's enabledModels settings (project over global)
|
||||
model-scope.ts # scopeModels allowlist policy, shared by top-level and nested tools
|
||||
mention.ts # `@handle message` grammar: suggestion triggers and send parsing
|
||||
mention-clone.ts # Run a mention's turn in a cloned conversation, off the main chat
|
||||
cross-extension-rpc.ts # RPC handlers for cross-extension spawn/ping via pi.events
|
||||
|
||||
# Scheduling
|
||||
schedule.ts # SubagentScheduler: cron / +10m / interval / ISO dispatch
|
||||
schedule-store.ts # PID-locked, session-scoped, atomic schedule persistence
|
||||
|
||||
# Context & environment
|
||||
memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
|
||||
skill-loader.ts # Preload skills (Pi-standard + Agent Skills spec layouts)
|
||||
output-file.ts # Streaming output file transcripts for agent sessions
|
||||
worktree.ts # Git worktree isolation (create, cleanup, prune)
|
||||
prompts.ts # Config-driven system prompt builder
|
||||
context.ts # Parent conversation context for inherit_context
|
||||
settings.ts # Persistent settings (~/.pi/agent/subagents.json + .pi/subagents.json)
|
||||
env.ts # Environment detection (git, platform)
|
||||
|
||||
ui/
|
||||
agent-widget.ts # Persistent widget: spinners, activity, status icons, theming
|
||||
fleet-list.ts # FleetView: navigable agent list below the editor
|
||||
conversation-viewer.ts # Live conversation overlay for viewing agent sessions
|
||||
viewer-keys.ts # Viewer scroll keys resolved through user keybindings
|
||||
agent-mention.ts # `@` roster (running, resumable, and startable agents) + popup rows
|
||||
schedule-menu.ts # /agents → Scheduled jobs submenu
|
||||
select-item.ts # Collision-safe ctx.ui.select wrapper (numbered rows)
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — [tintinweb](https://github.com/tintinweb)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Security Policy
|
||||
|
||||
This document explains the security model behind `@tintinweb/pi-subagents` and
|
||||
where the boundaries are.
|
||||
|
||||
`pi-subagents` is a [pi](https://pi.dev) extension. It spawns and orchestrates
|
||||
autonomous sub-agents that run locally within the same security boundary as the
|
||||
user running pi, and inherit pi's trust model. It is the responsibility of the
|
||||
user to monitor those agents' operations or to contain them within a container,
|
||||
virtual machine, or other sandbox solution.
|
||||
|
||||
Sub-agents run with the local user account's privileges and can use the tools
|
||||
they are granted (reading and writing files, running commands, network access,
|
||||
etc.). They treat the local user account and files writable by that account as
|
||||
inside the same trust boundary as the pi process itself. If an attacker can
|
||||
modify files under the user's home directory, workspace, shell startup files,
|
||||
environment, pi configuration, or this extension's configuration, they can
|
||||
generally influence pi, its sub-agents, or other local developer tools. Reports
|
||||
that depend on such prior local write access are not security vulnerabilities
|
||||
unless they demonstrate how `pi-subagents` grants that write access or crosses an
|
||||
operating-system privilege boundary.
|
||||
|
||||
`pi-subagents` relies on the user only loading trustworthy agent definitions
|
||||
(`.pi/agents/*.md`, `.agents/agents/*.md`, and global agents), skills, and
|
||||
tools, and only using pi within trusted repositories. Files like `AGENTS.md`,
|
||||
custom agent frontmatter/system prompts, preloaded skills, or instructions
|
||||
embedded in repository content and comments can be used to prompt-inject the
|
||||
coding agent and its sub-agents trivially, and this cannot be protected against.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you found a security vulnerability in `pi-subagents`, please
|
||||
report it privately by opening a draft advisory through
|
||||
[GitHub Security Advisories](https://github.com/tintinweb/pi-subagents/security/advisories/new)
|
||||
for this repository.
|
||||
|
||||
Please include:
|
||||
|
||||
- A description of the issue and its impact
|
||||
- Steps to reproduce, proof of concept, or relevant logs
|
||||
- Affected version, commit, or configuration
|
||||
- Any known mitigations
|
||||
|
||||
Do not open a public issue for security-sensitive reports. Reports will be
|
||||
reviewed and disclosure coordinated as appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
Security issues in the published npm package and the code in this repository are
|
||||
in scope — for example, a flaw in `pi-subagents` that crosses an
|
||||
operating-system privilege boundary, or that causes the extension to bypass a
|
||||
tool restriction, denylist, or agent boundary it claims to enforce.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Local code execution or sandboxing behavior (sub-agents intentionally do not
|
||||
have a sandbox and run with the user's privileges)
|
||||
- Behavior of pi itself, or of other pi extensions, skills, or tools installed by
|
||||
the user (report those to their respective projects)
|
||||
- Risks from working in untrusted repositories
|
||||
- Risks from installing or loading untrusted agent definitions, skills,
|
||||
extensions, packages, or tools
|
||||
- Issues caused by non-trustworthy MITM proxies
|
||||
- Public internet exposure of a pi installation
|
||||
- Prompt injection attacks (including via `AGENTS.md`, agent frontmatter, custom
|
||||
system prompts, preloaded skills, repository content, or context inheritance)
|
||||
- Exposed secrets that are third-party/user-controlled credentials
|
||||
- Reports requiring the ability to create, modify, delete, or replace files,
|
||||
directories, symlinks, environment variables, shell configuration, or other
|
||||
user-controlled local state on the target machine. This includes `.pi/agents/`,
|
||||
`.agents/agents/`, agent and extension configuration, persistent agent memory,
|
||||
workspace files, `AGENTS.md`, skills, dotfiles, and files synchronized through NFS, roaming
|
||||
profiles, or dotfile managers, unless the report shows how `pi-subagents`
|
||||
itself grants that access.
|
||||
- Issues caused by intentionally weakened user configuration
|
||||
- Resource/DOS claims that require trusted local input/config
|
||||
- Reports about malicious model output
|
||||
- User-approved or user-initiated local actions presented as vulnerabilities
|
||||
|
||||
## Notes for Reporters
|
||||
|
||||
The most useful reports show a current, reproducible security boundary bypass
|
||||
with demonstrated impact. Reports that only show expected local-agent behavior,
|
||||
prompt injection, or a malicious trusted agent definition/skill are not security
|
||||
vulnerabilities under this model.
|
||||
|
||||
For example, a report showing that malicious contents written to a trusted agent
|
||||
definition or extension configuration cause a sub-agent to execute commands, load
|
||||
attacker-controlled tools, or send credentials to an attacker-controlled endpoint
|
||||
is out of scope.
|
||||
|
||||
When possible, include the exact affected path, package version or commit SHA,
|
||||
configuration, and a proof of concept against the latest release or latest
|
||||
`master`. For dependency reports, include evidence that the shipped dependency is
|
||||
affected and that the issue is reachable through `pi-subagents`.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Upstream provenance
|
||||
|
||||
- Upstream: <https://github.com/tintinweb/pi-subagents>
|
||||
- Imported tag: `v0.18.0`
|
||||
- Imported commit: `3f9d35cd078d18a141eb5a6d8f4fc5010d756280`
|
||||
- Snapshot date: 2026-08-20
|
||||
- Upstream package version: `0.18.0`
|
||||
- License: MIT
|
||||
|
||||
## Local maintenance
|
||||
|
||||
This directory is maintained directly by the `my-pi` repository. It is not a submodule and must not contain a nested `.git`, upstream `.pi` state, `node_modules`, coverage output, or generated build output.
|
||||
|
||||
The initial import preserves the upstream TypeScript source, tests, examples, documentation, media, package metadata, and license. The upstream `.github/` automation and local `.pi/` state were intentionally not imported.
|
||||
|
||||
The source is included in the root package and installed through the root `file:./pi-subagents` dependency, while the host-owned `extensions/subagents.ts` wrapper is default-loaded from root `pi.extensions`. The local snapshot adds the documented permission child lifecycle bridge, root-authority forwarding identity, active-agent identity, and host-owned exact-path mandatory extension policy. The bundle wrapper protects `extensions/permission-system.ts` from Agent-controlled extension filtering and fails closed if that entry is unavailable. Root orchestration tools are assigned to Tool Search's checked-in `subagents` group, and Pi host peers are marked optional so the bundle does not install a second Pi runtime.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"recommended": false
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noEmptyInterface": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
||||
|
||||
Available agent types and the tools they have access to:
|
||||
{{typeList}}
|
||||
|
||||
Custom agents can be defined in .pi/agents/<name>.md (project) or {{agentDir}}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
|
||||
|
||||
When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
|
||||
|
||||
## When not to use
|
||||
|
||||
If the target is already known, use a direct tool — `read` for a known path, `grep`/`find` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
|
||||
|
||||
## Usage notes
|
||||
|
||||
- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
|
||||
- When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently. If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks.
|
||||
- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
|
||||
- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting the work as done.
|
||||
- Agents run in the background by default. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
|
||||
- **Foreground vs background**: Pass `run_in_background: false` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own.
|
||||
- **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess.
|
||||
- Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
|
||||
- Use steer_subagent to send mid-run messages to a running background agent.
|
||||
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
|
||||
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
||||
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
||||
- Use thinking to control extended thinking level.
|
||||
- Use inherit_context if the agent needs the parent conversation history.{{isolationGuideline}}{{scheduleGuideline}}
|
||||
|
||||
## Writing the prompt
|
||||
|
||||
Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
- Explain what you're trying to accomplish and why.
|
||||
- Describe what you've already learned or ruled out.
|
||||
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
||||
- If you need a short response, say so ("report in under 200 words").
|
||||
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
||||
|
||||
Terse command-style prompts produce shallow, generic work.
|
||||
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Generated
+5380
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "@tintinweb/pi-subagents",
|
||||
"version": "0.18.0",
|
||||
"description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
|
||||
"author": "tintinweb",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tintinweb/pi-subagents.git"
|
||||
},
|
||||
"homepage": "https://github.com/tintinweb/pi-subagents#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/tintinweb/pi-subagents/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi",
|
||||
"pi-extension",
|
||||
"subagent",
|
||||
"agent",
|
||||
"autonomous"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": ">=0.81.0",
|
||||
"@earendil-works/pi-coding-agent": ">=0.81.0",
|
||||
"@earendil-works/pi-tui": ">=0.81.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@earendil-works/pi-ai": {
|
||||
"optional": true
|
||||
},
|
||||
"@earendil-works/pi-coding-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"@earendil-works/pi-tui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"croner": "^10.0.1",
|
||||
"nanoid": "^5.1.16"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
||||
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "vitest run e2e --reporter=verbose",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check src/ test/",
|
||||
"lint:fix": "biome check --fix src/ test/",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@earendil-works/pi-ai": "0.84.2",
|
||||
"@earendil-works/pi-coding-agent": "0.84.2",
|
||||
"@earendil-works/pi-tui": "0.84.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-istanbul": "^4.1.10",
|
||||
"typescript": "^6.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
],
|
||||
"video": "https://github.com/tintinweb/pi-subagents/raw/master/media/demo.mp4",
|
||||
"image": "https://github.com/tintinweb/pi-subagents/raw/master/media/screenshot.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* abortable.ts — race a promise against an AbortSignal without cancelling the
|
||||
* underlying work.
|
||||
*
|
||||
* Used by the `get_subagent_result` wait paths (top-level and nested): pressing
|
||||
* Esc cancels only the caller's wait; the background child keeps running and its
|
||||
* result stays unconsumed. The listener is removed on every settle path so the
|
||||
* signal accumulates no handlers, and a late settlement of the wrapped promise
|
||||
* after an abort is absorbed as a no-op (no unhandled rejection).
|
||||
*/
|
||||
|
||||
/** Await a promise until it settles or the caller cancels, without aborting the underlying work. */
|
||||
export function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(signal.reason);
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(signal.reason);
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* agent-color.ts — Claude Code-compatible agent name badges.
|
||||
*
|
||||
* Claude Code renders a subagent's name as a badge: the configured color is the
|
||||
* background, the text an inverse foreground. Its eight named colors are
|
||||
* reproduced here, along with six-digit hex and the extra palette names Agency
|
||||
* Agents uses, so those definitions render as written.
|
||||
*/
|
||||
|
||||
import { getConfig } from "./agent-types.js";
|
||||
|
||||
const NAMED_AGENT_COLORS: Readonly<Record<string, string>> = {
|
||||
// Claude Code's eight subagent colors, as its default theme renders them.
|
||||
red: "#DC2626",
|
||||
blue: "#6A9BCC",
|
||||
green: "#16A34A",
|
||||
yellow: "#CA8A04",
|
||||
purple: "#827DBD",
|
||||
orange: "#D97757",
|
||||
pink: "#C46686",
|
||||
cyan: "#0891B2",
|
||||
// Agency Agents palette aliases.
|
||||
amber: "#F59E0B",
|
||||
teal: "#008080",
|
||||
indigo: "#6366F1",
|
||||
gold: "#EAB308",
|
||||
"neon-green": "#10B981",
|
||||
"neon-cyan": "#06B6D4",
|
||||
"metallic-blue": "#3B82F6",
|
||||
violet: "#8B5CF6",
|
||||
rose: "#F43F5E",
|
||||
lime: "#84CC16",
|
||||
gray: "#6B7280",
|
||||
grey: "#6B7280",
|
||||
fuchsia: "#D946EF",
|
||||
slate: "#64748B",
|
||||
navy: "#1E3A8A",
|
||||
};
|
||||
|
||||
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
||||
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
|
||||
const BLACK = { r: 0, g: 0, b: 0 };
|
||||
const WHITE = { r: 255, g: 255, b: 255 };
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
type ColorMode = "truecolor" | "256color";
|
||||
|
||||
export interface AgentNameTheme {
|
||||
fg(color: string, text: string): string;
|
||||
bold(text: string): string;
|
||||
getColorMode?(): ColorMode;
|
||||
}
|
||||
|
||||
export interface AgentNameStyle {
|
||||
/** Existing theme foreground used when no valid agent color is configured. */
|
||||
fallbackColor?: string;
|
||||
/** Reapply an enclosing background after the badge instead of resetting it. */
|
||||
restoreBackground?: string;
|
||||
bold?: boolean;
|
||||
}
|
||||
|
||||
/** Resolve Claude Code/Agency Agents color syntax to normalized #RRGGBB. */
|
||||
export function resolveAgentColor(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
const resolved = NAMED_AGENT_COLORS[normalized] ?? normalized;
|
||||
return /^#[0-9a-f]{6}$/i.test(resolved) ? resolved.toUpperCase() : undefined;
|
||||
}
|
||||
|
||||
function parseHex(hex: string): Rgb {
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the entry in `values` closest to `value`. */
|
||||
function nearest(values: readonly number[], value: number): number {
|
||||
return values.reduce((best, v, i) => (Math.abs(value - v) < Math.abs(value - values[best]) ? i : best), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantize to the xterm-256 palette the way pi's own theme does, returning both
|
||||
* the index to emit and the color the terminal will actually show — badge
|
||||
* contrast is judged against the latter.
|
||||
*/
|
||||
function rgbTo256({ r, g, b }: Rgb): { index: number; rgb: Rgb } {
|
||||
const [rIndex, gIndex, bIndex] = [r, g, b].map((channel) => nearest(CUBE_VALUES, channel));
|
||||
const distance = ({ r: cr, g: cg, b: cb }: Rgb) => 0.299 * (r - cr) ** 2 + 0.587 * (g - cg) ** 2 + 0.114 * (b - cb) ** 2;
|
||||
const grayIndex = nearest(GRAY_VALUES, Math.round(0.299 * r + 0.587 * g + 0.114 * b));
|
||||
const gray = { r: GRAY_VALUES[grayIndex], g: GRAY_VALUES[grayIndex], b: GRAY_VALUES[grayIndex] };
|
||||
const cube = { r: CUBE_VALUES[rIndex], g: CUBE_VALUES[gIndex], b: CUBE_VALUES[bIndex] };
|
||||
// Only near-neutral colors may take the gray ramp; anything else keeps its tint.
|
||||
if (Math.max(r, g, b) - Math.min(r, g, b) < 10 && distance(gray) < distance(cube)) {
|
||||
return { index: 232 + grayIndex, rgb: gray };
|
||||
}
|
||||
return { index: 16 + 36 * rIndex + 6 * gIndex + bIndex, rgb: cube };
|
||||
}
|
||||
|
||||
function ansiColor(layer: "foreground" | "background", color: Rgb | number): string {
|
||||
const code = layer === "foreground" ? 38 : 48;
|
||||
return typeof color === "number"
|
||||
? `\u001b[${code};5;${color}m`
|
||||
: `\u001b[${code};2;${color.r};${color.g};${color.b}m`;
|
||||
}
|
||||
|
||||
function relativeLuminance({ r, g, b }: Rgb): number {
|
||||
const linear = (value: number) => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one name as a padded background badge when `color` is valid. Claude
|
||||
* Code uses one inverse color for every badge's text; black or white is picked
|
||||
* by WCAG contrast here instead, so each palette entry stays readable. Invalid
|
||||
* or omitted colors preserve the caller's existing theme styling.
|
||||
*/
|
||||
export function renderAgentNameLabel(
|
||||
name: string,
|
||||
color: string | undefined,
|
||||
theme: AgentNameTheme,
|
||||
style: AgentNameStyle = {},
|
||||
): string {
|
||||
const resolved = resolveAgentColor(color);
|
||||
if (!resolved) {
|
||||
const text = style.bold ? theme.bold(name) : name;
|
||||
return style.fallbackColor ? theme.fg(style.fallbackColor, text) : text;
|
||||
}
|
||||
|
||||
const rgb = parseHex(resolved);
|
||||
const quantized = (theme.getColorMode?.() ?? "truecolor") === "256color" ? rgbTo256(rgb) : undefined;
|
||||
const shown = quantized?.rgb ?? rgb;
|
||||
const contrasting = relativeLuminance(shown) > 0.179 ? BLACK : WHITE;
|
||||
const label = style.bold ? theme.bold(` ${name} `) : ` ${name} `;
|
||||
|
||||
return ansiColor("background", quantized?.index ?? rgb)
|
||||
+ ansiColor("foreground", quantized ? rgbTo256(contrasting).index : contrasting)
|
||||
+ label
|
||||
+ "\u001b[39m"
|
||||
+ (style.restoreBackground ?? "\u001b[49m");
|
||||
}
|
||||
|
||||
/** Whether an agent renders as a badge — i.e. it has a valid configured color. */
|
||||
export function hasAgentBadge(type: string | undefined): boolean {
|
||||
return type !== undefined && resolveAgentColor(getConfig(type).color) !== undefined;
|
||||
}
|
||||
|
||||
/** Render a registered agent's display name with its configured color. */
|
||||
export function renderAgentName(
|
||||
type: string | undefined,
|
||||
theme: AgentNameTheme,
|
||||
style: AgentNameStyle = {},
|
||||
): string {
|
||||
if (!type) return renderAgentNameLabel("Agent", undefined, theme, style);
|
||||
const config = getConfig(type);
|
||||
return renderAgentNameLabel(config.displayName, config.color, theme, style);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* agent-file-toggle.ts — Pure helpers for the `/agents` file-editing operations:
|
||||
* locating an agent's .md file, toggling its `enabled:` frontmatter flag, and
|
||||
* serializing an AgentConfig back to frontmatter for eject.
|
||||
*
|
||||
* These live outside src/index.ts so they can be tested directly: the `/agents`
|
||||
* command handler is an ~890-line closure reached only through `registerCommand`,
|
||||
* which every test mocks.
|
||||
*
|
||||
* The read side of this data (src/custom-agents.ts) parses frontmatter with a
|
||||
* real YAML parser, so it honors `enabled: false` at any position in the block.
|
||||
* This module must agree with it, and splits the work accordingly:
|
||||
*
|
||||
* - Deciding whether a file is disabled is a *read*, so it calls that same parser
|
||||
* (`isDisabledContent`) instead of mirroring it. A mirror has to be right about
|
||||
* YAML's boolean spellings and about pi's fence scan, and a regex was wrong
|
||||
* about both.
|
||||
* - *Editing* cannot go through the parser, because re-serializing a parsed
|
||||
* document would reformat a file the README tells users to hand-author —
|
||||
* discarding their comments, key order, and quoting. So the edits are line-wise
|
||||
* and preserve everything they don't touch.
|
||||
*
|
||||
* That leaves removal best-effort: it recognizes a lowercase bare `false`, and
|
||||
* reports `changed: false` for the spellings it cannot rewrite, so the caller
|
||||
* refuses honestly rather than announcing a change it did not make.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
import type { AgentConfig } from "./types.js";
|
||||
|
||||
export type AgentFileLocation = "project" | "workspace" | "personal";
|
||||
|
||||
export const projectAgentsDir = (cwd: string = process.cwd()) => join(cwd, ".pi", "agents");
|
||||
export const workspaceAgentsDir = (cwd: string = process.cwd()) => join(cwd, ".agents", "agents");
|
||||
export const personalAgentsDir = () => join(getAgentDir(), "agents");
|
||||
|
||||
/**
|
||||
* Find the file path of a custom agent by name, in discovery-precedence order
|
||||
* (project, workspace, then global). Mirrors the load-side precedence in
|
||||
* src/custom-agents.ts — if the two drift, `/agents` edits a file the loader
|
||||
* isn't reading.
|
||||
*/
|
||||
export function findAgentFile(
|
||||
name: string,
|
||||
cwd: string = process.cwd(),
|
||||
): { path: string; location: AgentFileLocation } | undefined {
|
||||
const projectPath = join(projectAgentsDir(cwd), `${name}.md`);
|
||||
if (existsSync(projectPath)) return { path: projectPath, location: "project" };
|
||||
const workspacePath = join(workspaceAgentsDir(cwd), `${name}.md`);
|
||||
if (existsSync(workspacePath)) return { path: workspacePath, location: "workspace" };
|
||||
const personalPath = join(personalAgentsDir(), `${name}.md`);
|
||||
if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the file behind a *loaded* agent, preferring the path the loader
|
||||
* actually read (`AgentConfig.sourcePath`) over the `<type>.md` guess.
|
||||
*
|
||||
* An agent's type comes from its frontmatter `name:` now, so the two can
|
||||
* disagree: `reviewer.md` declaring `name: code-reviewer` is loaded as
|
||||
* `code-reviewer`, and probing for `code-reviewer.md` finds nothing. That is
|
||||
* not a harmless miss — `/agents → Disable` would then take the no-file branch
|
||||
* and write a NEW `code-reviewer.md` stub, which loses to `reviewer.md` on
|
||||
* load, leaving the agent enabled while reporting success.
|
||||
*
|
||||
* The probe stays as the fallback: a built-in that was never ejected has no
|
||||
* `sourcePath`, and a path can go stale between a load and this call.
|
||||
*/
|
||||
export function locateAgentFile(
|
||||
name: string,
|
||||
sourcePath: string | undefined,
|
||||
cwd: string = process.cwd(),
|
||||
): { path: string; location: AgentFileLocation } | undefined {
|
||||
if (sourcePath && existsSync(sourcePath)) {
|
||||
return { path: sourcePath, location: classifyAgentDir(sourcePath, cwd) };
|
||||
}
|
||||
return findAgentFile(name, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which discovery location a loaded agent's file came from. Only ever names
|
||||
* a directory in a confirmation prompt, so an unrecognized parent — which
|
||||
* loadCustomAgents cannot currently produce — reports as personal rather than
|
||||
* widening the type for a case that has no better answer.
|
||||
*/
|
||||
function classifyAgentDir(path: string, cwd: string): AgentFileLocation {
|
||||
if (path.startsWith(projectAgentsDir(cwd) + sep)) return "project";
|
||||
if (path.startsWith(workspaceAgentsDir(cwd) + sep)) return "workspace";
|
||||
return "personal";
|
||||
}
|
||||
|
||||
export type DisableOutcome = "disabled" | "already-disabled" | "no-frontmatter";
|
||||
|
||||
/** A line that sets `enabled: false`, ignoring trailing whitespace / CR. */
|
||||
const ENABLED_FALSE = /^enabled:[ \t]*false[ \t]*$/;
|
||||
/** An opening or closing `---` fence line. */
|
||||
const FENCE = /^---[ \t]*$/;
|
||||
|
||||
/**
|
||||
* Split a file into its frontmatter lines and everything else, agreeing with
|
||||
* what `parseFrontmatter` (the load side) considers a frontmatter block.
|
||||
*
|
||||
* Lines keep their terminators, so an edit preserves the file's existing line
|
||||
* endings instead of rewriting CRLF to LF. Returns undefined when there is no
|
||||
* usable block — notably for a BOM-prefixed file, which the parser also reads
|
||||
* as having none, so writing a key into it would change nothing on load.
|
||||
*/
|
||||
function splitFrontmatter(content: string):
|
||||
| { lines: string[]; openIdx: number; closeIdx: number; eol: string }
|
||||
| undefined {
|
||||
const lines = content.split(/(?<=\n)/);
|
||||
if (lines.length === 0 || !FENCE.test(lines[0].replace(/\r?\n$/, ""))) return undefined;
|
||||
const closeIdx = lines.findIndex((l, i) => i > 0 && FENCE.test(l.replace(/\r?\n$/, "")));
|
||||
if (closeIdx === -1) return undefined;
|
||||
return { lines, openIdx: 0, closeIdx, eol: lines[0].endsWith("\r\n") ? "\r\n" : "\n" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the loader consider this file disabled?
|
||||
*
|
||||
* Detection is a READ operation, so it asks the same parser the loader uses
|
||||
* rather than mirroring it with a regex — that mirror has to be right about
|
||||
* YAML's boolean spellings (`False`, `FALSE`, a trailing `# comment`, a quoted
|
||||
* key) *and* about pi's fence scan, which closes the block on any line starting
|
||||
* `---` and so ends it early on `----`. A throw means the file is already
|
||||
* unparseable, which is what the loader sees too: it skips the agent, so there
|
||||
* is no "disabled" state to report.
|
||||
*/
|
||||
export function isDisabledContent(content: string): boolean {
|
||||
try {
|
||||
return parseFrontmatter<Record<string, unknown>>(content).frontmatter.enabled === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add `enabled: false` to a file's frontmatter.
|
||||
*
|
||||
* `outcome` distinguishes a real edit from a no-op so the caller can report
|
||||
* honestly instead of unconditionally claiming success.
|
||||
*/
|
||||
export function disableInContent(content: string): { content: string; outcome: DisableOutcome } {
|
||||
const block = splitFrontmatter(content);
|
||||
if (!block) return { content, outcome: "no-frontmatter" };
|
||||
if (isDisabledContent(content)) return { content, outcome: "already-disabled" };
|
||||
const lines = [...block.lines];
|
||||
lines.splice(1, 0, `enabled: false${block.eol}`);
|
||||
return { content: lines.join(""), outcome: "disabled" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `enabled: false` from a file's frontmatter, wherever it appears in the
|
||||
* block — the loader honors the key at any position, so the two must agree or a
|
||||
* hand-authored agent can be disabled and never re-enabled.
|
||||
*
|
||||
* `changed` is false when the key wasn't found, so the caller can avoid
|
||||
* reporting "Enabled <name>" for a write that did nothing.
|
||||
*/
|
||||
export function enableInContent(content: string): { content: string; changed: boolean } {
|
||||
const block = splitFrontmatter(content);
|
||||
if (!block) return { content, changed: false };
|
||||
const kept = block.lines.filter(
|
||||
(l, i) => !(i > 0 && i < block.closeIdx && ENABLED_FALSE.test(l.replace(/\r?\n$/, ""))),
|
||||
);
|
||||
if (kept.length === block.lines.length) return { content, changed: false };
|
||||
return { content: kept.join(""), changed: true };
|
||||
}
|
||||
|
||||
/** Is this the empty stub `/agents` writes when disabling a built-in default? */
|
||||
export function isEmptyStub(content: string): boolean {
|
||||
return content.replace(/\r\n/g, "\n").trim() === "---\n---";
|
||||
}
|
||||
|
||||
/** The answers `/agents → Create agent → Manual` collects, before serialization. */
|
||||
export interface NewAgentInput {
|
||||
description: string;
|
||||
/** Already-resolved `tools:` value ("none", "all", or a CSV of tool names). */
|
||||
tools: string;
|
||||
/** `provider/modelId`, or undefined to inherit the parent's model. */
|
||||
model?: string;
|
||||
/** A pi thinking level, or undefined to inherit. */
|
||||
thinking?: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the .md file the create wizard writes.
|
||||
*
|
||||
* `description` and `model` come straight from a free-text prompt, so they are
|
||||
* quoted rather than interpolated — `serializeAgentFile` above quotes the
|
||||
* description for the same reason. An unquoted YAML scalar mishandles ordinary
|
||||
* input in two ways, and both are silent: a colon ("Scout: find things") makes
|
||||
* the file unparseable, and since #212 an unparseable agent file is *skipped*,
|
||||
* so the wizard reports success for an agent that does not exist; a `#`
|
||||
* ("audit #security") opens a comment and truncates the value. `model` can
|
||||
* carry a colon too — pi accepts a `provider/model:thinking` suffix.
|
||||
*
|
||||
* `tools` and `thinking` are not quoted: both are chosen from fixed menus, and
|
||||
* `tools` is a CSV that must stay a bare scalar for the loader's parser.
|
||||
*/
|
||||
export function buildNewAgentFile(input: NewAgentInput): string {
|
||||
const modelLine = input.model ? `\nmodel: ${JSON.stringify(input.model)}` : "";
|
||||
const thinkingLine = input.thinking ? `\nthinking: ${input.thinking}` : "";
|
||||
return `---
|
||||
description: ${JSON.stringify(input.description)}
|
||||
tools: ${input.tools}${modelLine}${thinkingLine}
|
||||
prompt_mode: replace
|
||||
---
|
||||
|
||||
${input.systemPrompt}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Render a built-in tool list as a `tools:` frontmatter value. */
|
||||
function formatToolsField(tools: string[] | undefined): string {
|
||||
if (tools === undefined) return "all";
|
||||
if (tools.length === 0) return "none";
|
||||
return tools.join(", ");
|
||||
}
|
||||
|
||||
/** Serialize an AgentConfig to a full .md file (frontmatter + system prompt) for eject. */
|
||||
export function serializeAgentFile(cfg: AgentConfig): string {
|
||||
const fmFields: string[] = [];
|
||||
fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
|
||||
if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
|
||||
if (cfg.color) fmFields.push(`color: ${JSON.stringify(cfg.color)}`);
|
||||
// Absent means "all built-ins"; an EMPTY list means explicitly zero. Writing
|
||||
// `all` for both would hand a deliberately tool-less agent the whole toolbox
|
||||
// the first time it is ejected.
|
||||
fmFields.push(`tools: ${formatToolsField(cfg.builtinToolNames)}`);
|
||||
if (cfg.model) fmFields.push(`model: ${cfg.model}`);
|
||||
if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
|
||||
if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
|
||||
if (cfg.allowedSubagents !== undefined) {
|
||||
fmFields.push(`allowed_subagents: ${cfg.allowedSubagents === "all" ? "all" : cfg.allowedSubagents.join(", ")}`);
|
||||
}
|
||||
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
||||
if (cfg.extensions === false) fmFields.push("extensions: false");
|
||||
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
||||
if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
|
||||
if (cfg.skills === false) fmFields.push("skills: false");
|
||||
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
||||
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
||||
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
||||
// Both cases, not just `true`: with `backgroundByDefault` on, omitting the
|
||||
// field means background, so `false` is the only way to pin an agent file to
|
||||
// foreground and is no longer interchangeable with absence. No caller can
|
||||
// reach it yet — Eject only handles built-in defaults, which omit the field —
|
||||
// so this keeps the writer symmetric with the loader, nothing more.
|
||||
if (cfg.runInBackground !== undefined) fmFields.push(`run_in_background: ${cfg.runInBackground}`);
|
||||
if (cfg.outputTranscript === false) fmFields.push("output_transcript: false");
|
||||
if (cfg.isolated) fmFields.push("isolated: true");
|
||||
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
||||
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
||||
|
||||
return `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* agent-types.ts — Unified agent type registry.
|
||||
*
|
||||
* Merges embedded default agents with user-defined agents from .pi/agents/*.md, .agents/agents/*.md, and global agents.
|
||||
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
|
||||
*/
|
||||
|
||||
import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
|
||||
import { DEFAULT_AGENTS } from "./default-agents.js";
|
||||
import type { AgentConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
* All known built-in tool names, derived from pi's own tool factories rather
|
||||
* than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
|
||||
* `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
|
||||
* read/grep/find/ls; their de-duplicated union is the 7 built-ins
|
||||
* (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
|
||||
* operations we never invoke here — we read each tool's `.name` and discard it.
|
||||
*/
|
||||
export const BUILTIN_TOOL_NAMES: string[] = [
|
||||
...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
|
||||
];
|
||||
|
||||
/** Unified runtime registry of all agents (defaults + user-defined). */
|
||||
const agents = new Map<string, AgentConfig>();
|
||||
|
||||
/** When true, DEFAULT_AGENTS are skipped during registration. */
|
||||
let disableDefaults = false;
|
||||
|
||||
/** Check whether default agents are disabled. */
|
||||
export function isDefaultsDisabled(): boolean { return disableDefaults; }
|
||||
|
||||
/** Set whether default agents are disabled. */
|
||||
export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
|
||||
|
||||
/** `fallbackSubagent` value that disables the fallback entirely (strict dispatch). */
|
||||
export const NO_FALLBACK = "none";
|
||||
|
||||
/**
|
||||
* Agent type substituted when a caller-supplied `subagent_type` doesn't resolve
|
||||
* to exactly one enabled agent. `undefined` keeps the historical behavior
|
||||
* (general-purpose); `NO_FALLBACK` makes dispatch fail closed. Set from
|
||||
* `subagents.json` (`fallbackSubagent`).
|
||||
*
|
||||
* Module state rather than an index.ts closure because every caller-supplied
|
||||
* spawn path needs it — the Agent tool, the scheduler, and cross-extension RPC.
|
||||
*/
|
||||
let fallbackSubagent: string | undefined;
|
||||
|
||||
/** Get the configured fallback agent type. undefined = general-purpose. */
|
||||
export function getFallbackSubagent(): string | undefined { return fallbackSubagent; }
|
||||
|
||||
/** Set the configured fallback agent type. undefined = general-purpose. */
|
||||
export function setFallbackSubagent(v: string | undefined): void { fallbackSubagent = v; }
|
||||
|
||||
/**
|
||||
* Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
|
||||
* then user agents overlaid on top (same name overrides the default).
|
||||
* Pure — callers that must not disturb the process-wide registry (nested
|
||||
* delegation resolving agents from its own config root) build their own map.
|
||||
*/
|
||||
export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> {
|
||||
const registry = new Map<string, AgentConfig>();
|
||||
if (!disableDefaults) {
|
||||
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
|
||||
}
|
||||
for (const [name, config] of userAgents) registry.set(name, config);
|
||||
return registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register agents into the unified registry.
|
||||
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
|
||||
* Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
|
||||
*/
|
||||
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
|
||||
agents.clear();
|
||||
for (const [name, config] of buildAgentRegistry(userAgents)) {
|
||||
agents.set(name, config);
|
||||
}
|
||||
}
|
||||
|
||||
/** Case-insensitive key resolution within a registry. */
|
||||
function resolveKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
||||
if (registry.has(name)) return name;
|
||||
const lower = name.toLowerCase();
|
||||
for (const key of registry.keys()) {
|
||||
if (key.toLowerCase() === lower) return key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Case-insensitive key resolution. */
|
||||
function resolveKey(name: string): string | undefined {
|
||||
return resolveKeyIn(agents, name);
|
||||
}
|
||||
|
||||
/** Resolve a type name case-insensitively in a registry. Returns the canonical key or undefined. */
|
||||
export function resolveTypeIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
||||
return resolveKeyIn(registry, name);
|
||||
}
|
||||
|
||||
/** Get the agent config for a type (case-insensitive) from a registry. */
|
||||
export function getAgentConfigIn(registry: Map<string, AgentConfig>, name: string): AgentConfig | undefined {
|
||||
const key = resolveKeyIn(registry, name);
|
||||
return key ? registry.get(key) : undefined;
|
||||
}
|
||||
|
||||
/** Check if a type is valid and enabled (case-insensitive) in a registry. */
|
||||
export function isValidTypeIn(registry: Map<string, AgentConfig>, type: string): boolean {
|
||||
const key = resolveKeyIn(registry, type);
|
||||
if (!key) return false;
|
||||
return registry.get(key)?.enabled !== false;
|
||||
}
|
||||
|
||||
/** Get all enabled type names in a registry (for spawning and tool descriptions). */
|
||||
export function getAvailableTypesIn(registry: Map<string, AgentConfig>): string[] {
|
||||
return [...registry.entries()]
|
||||
.filter(([_, config]) => config.enabled !== false)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive resolution that refuses to guess. An exact match always wins;
|
||||
* otherwise the name must match exactly one key. Two agents differing only in
|
||||
* case are reachable (`loadCustomAgents` keys by filename across three
|
||||
* directories), and picking whichever came first would silently dispatch a
|
||||
* different agent, model and tool policy than the caller meant.
|
||||
*/
|
||||
function resolveUnambiguousKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
|
||||
if (registry.has(name)) return name;
|
||||
const lower = name.toLowerCase();
|
||||
const matches = [...registry.keys()].filter(key => key.toLowerCase() === lower);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical key for a caller-supplied name that identifies exactly one
|
||||
* ENABLED agent, or undefined. Strict by construction: no fallback, no guessing
|
||||
* between case-variants. Nested delegation resolves with this directly, since
|
||||
* "unknown types are rejected rather than falling back" is its own contract.
|
||||
*/
|
||||
export function resolveEnabledTypeIn(
|
||||
registry: Map<string, AgentConfig>,
|
||||
requested: unknown,
|
||||
): string | undefined {
|
||||
const raw = typeof requested === "string" ? requested.trim() : "";
|
||||
if (!raw) return undefined;
|
||||
const key = resolveUnambiguousKeyIn(registry, raw);
|
||||
return key !== undefined && registry.get(key)?.enabled !== false ? key : undefined;
|
||||
}
|
||||
|
||||
/** Outcome of resolving a caller-supplied `subagent_type` into a spawnable type. */
|
||||
export type SpawnTypeResolution =
|
||||
/** Spawn this type. `fellBackFrom` is set when it isn't what the caller asked for. */
|
||||
| { ok: true; type: string; fellBackFrom?: string }
|
||||
/** Refuse the spawn and return this message to the caller. */
|
||||
| { ok: false; message: string };
|
||||
|
||||
/**
|
||||
* Resolve a caller-supplied agent type against a registry, applying the
|
||||
* `fallbackSubagent` policy. The single decision point for every caller-supplied
|
||||
* spawn — the Agent tool, the scheduler, cross-extension RPC, and the nested
|
||||
* tools — so a type that fails here never reaches `runAgent`, where `getConfig`
|
||||
* would silently substitute general-purpose.
|
||||
*
|
||||
* Unknown, disabled, and case-ambiguous names are all treated the same way:
|
||||
* the caller named something that doesn't identify exactly one enabled agent.
|
||||
*
|
||||
* Pure over `registry` — callers that need fresh agent files reload before
|
||||
* calling (the Agent tool already does, per spawn). Reloading here would mean
|
||||
* importing custom-agents.ts, which imports this module.
|
||||
*/
|
||||
export function resolveSpawnTypeIn(
|
||||
registry: Map<string, AgentConfig>,
|
||||
requested: unknown,
|
||||
): SpawnTypeResolution {
|
||||
const raw = typeof requested === "string" ? requested.trim() : "";
|
||||
const available = () => getAvailableTypesIn(registry).join(", ") || "(none)";
|
||||
|
||||
const key = resolveEnabledTypeIn(registry, raw);
|
||||
if (key !== undefined) return { ok: true, type: key };
|
||||
|
||||
// A missing type follows the same policy as a wrong one rather than always
|
||||
// erroring: before this setting existed an empty type fell back like any
|
||||
// other unresolvable name, and only opting in should change that.
|
||||
const reason = raw ? `Unknown or disabled agent type: "${raw}".` : "No agent type given.";
|
||||
|
||||
// Trimmed like `requested`: a padded value set programmatically would
|
||||
// otherwise be reported as a missing agent.
|
||||
const configured = typeof fallbackSubagent === "string" ? fallbackSubagent.trim() : undefined;
|
||||
|
||||
if (configured !== undefined && configured.toLowerCase() === NO_FALLBACK) {
|
||||
return { ok: false, message: `${reason} Available: ${available()}.` };
|
||||
}
|
||||
|
||||
if (configured !== undefined) {
|
||||
// An explicitly configured fallback that is itself unusable is a
|
||||
// misconfiguration, not a second chance to guess — say so rather than
|
||||
// quietly dropping to general-purpose.
|
||||
const fallbackKey = resolveUnambiguousKeyIn(registry, configured);
|
||||
if (fallbackKey === undefined || registry.get(fallbackKey)?.enabled === false) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`${reason} The configured fallbackSubagent "${configured}" is itself ` +
|
||||
`unknown or disabled. Available: ${available()}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, type: fallbackKey, fellBackFrom: raw };
|
||||
}
|
||||
|
||||
// Unset: historical behavior, deliberately unchanged. #183 asks for the
|
||||
// fallback to remain the default, so the pre-existing hole it leaves — an
|
||||
// unregistered general-purpose resolving to `getConfig`'s all-tools hardcoded
|
||||
// tier — is what `fallbackSubagent: none` is for, not something to close
|
||||
// under everyone silently.
|
||||
return { ok: true, type: "general-purpose", fellBackFrom: raw };
|
||||
}
|
||||
|
||||
/** Resolve a caller-supplied agent type against the process-wide registry. */
|
||||
export function resolveSpawnType(requested: unknown): SpawnTypeResolution {
|
||||
return resolveSpawnTypeIn(agents, requested);
|
||||
}
|
||||
|
||||
/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
|
||||
export function resolveType(name: string): string | undefined {
|
||||
return resolveKey(name);
|
||||
}
|
||||
|
||||
/** Get the agent config for a type (case-insensitive). */
|
||||
export function getAgentConfig(name: string): AgentConfig | undefined {
|
||||
return getAgentConfigIn(agents, name);
|
||||
}
|
||||
|
||||
/** Get all enabled type names (for spawning and tool descriptions). */
|
||||
export function getAvailableTypes(): string[] {
|
||||
return getAvailableTypesIn(agents);
|
||||
}
|
||||
|
||||
/** Get all type names including disabled (for UI listing). */
|
||||
export function getAllTypes(): string[] {
|
||||
return [...agents.keys()];
|
||||
}
|
||||
|
||||
/** Get names of default agents currently in the registry. */
|
||||
export function getDefaultAgentNames(): string[] {
|
||||
return [...agents.entries()]
|
||||
.filter(([_, config]) => config.isDefault === true)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/** Get names of user-defined agents (non-defaults) currently in the registry. */
|
||||
export function getUserAgentNames(): string[] {
|
||||
return [...agents.entries()]
|
||||
.filter(([_, config]) => config.isDefault !== true)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/** Check if a type is valid and enabled (case-insensitive). */
|
||||
export function isValidType(type: string): boolean {
|
||||
return isValidTypeIn(agents, type);
|
||||
}
|
||||
|
||||
/** Tool names required for memory management. */
|
||||
const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
|
||||
|
||||
/**
|
||||
* Get memory tool names (read/write/edit) not already in the provided set.
|
||||
*/
|
||||
export function getMemoryToolNames(existingToolNames: Set<string>): string[] {
|
||||
return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
|
||||
}
|
||||
|
||||
/** Tool names needed for read-only memory access. */
|
||||
const READONLY_MEMORY_TOOL_NAMES = ["read"];
|
||||
|
||||
/**
|
||||
* Get read-only memory tool names not already in the provided set.
|
||||
*/
|
||||
export function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[] {
|
||||
return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
|
||||
}
|
||||
|
||||
/** Get built-in tool names for a type (case-insensitive). */
|
||||
export function getToolNamesForType(type: string): string[] {
|
||||
const key = resolveKey(type);
|
||||
const raw = key ? agents.get(key) : undefined;
|
||||
const config = raw?.enabled !== false ? raw : undefined;
|
||||
// `undefined` (definition omitted the field) → all built-ins; an explicit `[]`
|
||||
// (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins.
|
||||
return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES];
|
||||
}
|
||||
|
||||
/** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
|
||||
export function getConfig(type: string): {
|
||||
displayName: string;
|
||||
color?: string;
|
||||
description: string;
|
||||
builtinToolNames: string[];
|
||||
extensions: true | string[] | false;
|
||||
excludeExtensions?: string[];
|
||||
skills: true | string[] | false;
|
||||
promptMode: "replace" | "append";
|
||||
} {
|
||||
const key = resolveKey(type);
|
||||
const config = key ? agents.get(key) : undefined;
|
||||
if (config && config.enabled !== false) {
|
||||
return {
|
||||
displayName: config.displayName ?? config.name,
|
||||
color: config.color,
|
||||
description: config.description,
|
||||
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
||||
extensions: config.extensions,
|
||||
excludeExtensions: config.excludeExtensions,
|
||||
skills: config.skills,
|
||||
promptMode: config.promptMode,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown/disabled types — general-purpose config
|
||||
const gp = agents.get("general-purpose");
|
||||
if (gp && gp.enabled !== false) {
|
||||
return {
|
||||
displayName: gp.displayName ?? gp.name,
|
||||
color: gp.color,
|
||||
description: gp.description,
|
||||
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
||||
extensions: gp.extensions,
|
||||
excludeExtensions: gp.excludeExtensions,
|
||||
skills: gp.skills,
|
||||
promptMode: gp.promptMode,
|
||||
};
|
||||
}
|
||||
|
||||
// Absolute fallback (should never happen)
|
||||
return {
|
||||
displayName: "Agent",
|
||||
description: "General-purpose agent for complex, multi-step tasks",
|
||||
builtinToolNames: BUILTIN_TOOL_NAMES,
|
||||
extensions: true,
|
||||
skills: true,
|
||||
promptMode: "append",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
/**
|
||||
* Marks resource loading/session construction performed for a subagent. This is
|
||||
* async-context-local so concurrent top-level extension work is unaffected.
|
||||
*/
|
||||
const childSessionContext = new AsyncLocalStorage<boolean>();
|
||||
|
||||
export function inChildSessionContext(): boolean {
|
||||
return childSessionContext.getStore() === true;
|
||||
}
|
||||
|
||||
export function runInChildSessionContext<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return childSessionContext.run(true, fn);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* context.ts — Extract parent conversation context for subagent inheritance.
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** Extract text from a message content block array. */
|
||||
export function extractText(content: unknown[]): string {
|
||||
return content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text ?? "")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a text representation of the parent conversation context.
|
||||
* Used when inherit_context is true to give the subagent visibility
|
||||
* into what has been discussed/done so far.
|
||||
*/
|
||||
export function buildParentContext(ctx: ExtensionContext): string {
|
||||
const entries = ctx.sessionManager.getBranch();
|
||||
if (!entries || entries.length === 0) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "message") {
|
||||
const msg = entry.message;
|
||||
if (msg.role === "user") {
|
||||
const text = typeof msg.content === "string"
|
||||
? msg.content
|
||||
: extractText(msg.content);
|
||||
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
||||
} else if (msg.role === "assistant") {
|
||||
const text = extractText(msg.content);
|
||||
if (text.trim()) parts.push(`[Assistant]: ${text.trim()}`);
|
||||
}
|
||||
// Skip toolResult messages — too verbose for context
|
||||
} else if (entry.type === "compaction") {
|
||||
// Include compaction summaries — they're already condensed
|
||||
if (entry.summary) {
|
||||
parts.push(`[Summary]: ${entry.summary}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) return "";
|
||||
|
||||
return `# Parent Conversation Context
|
||||
The following is the conversation history from the parent session that spawned you.
|
||||
Use this context to understand what has been discussed and decided so far.
|
||||
|
||||
${parts.join("\n\n")}
|
||||
|
||||
---
|
||||
# Your Task (below)
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Cross-extension RPC handlers for the subagents extension.
|
||||
*
|
||||
* Exposes ping, spawn, and stop RPCs over the pi.events event bus,
|
||||
* using per-request scoped reply channels.
|
||||
*
|
||||
* Reply envelope follows pi-mono convention:
|
||||
* success → { success: true, data?: T }
|
||||
* error → { success: false, error: string }
|
||||
*/
|
||||
|
||||
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
||||
|
||||
/** Minimal event bus interface needed by the RPC handlers. */
|
||||
export interface EventBus {
|
||||
on(event: string, handler: (data: unknown) => void): () => void;
|
||||
emit(event: string, data: unknown): void;
|
||||
}
|
||||
|
||||
/** RPC reply envelope — matches pi-mono's RpcResponse shape. */
|
||||
export type RpcReply<T = void> =
|
||||
| { success: true; data?: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
/** RPC protocol version — bumped when the envelope or method contracts change. */
|
||||
export const PROTOCOL_VERSION = 2;
|
||||
|
||||
/** Minimal AgentManager interface needed by the spawn/stop RPCs. */
|
||||
export interface SpawnCapable {
|
||||
spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
|
||||
abort(id: string): boolean;
|
||||
}
|
||||
|
||||
export interface RpcDeps {
|
||||
events: EventBus;
|
||||
pi: unknown; // passed through to manager.spawn
|
||||
getCtx: () => unknown | undefined; // returns current ExtensionContext
|
||||
manager: SpawnCapable;
|
||||
}
|
||||
|
||||
export interface RpcHandle {
|
||||
unsubPing: () => void;
|
||||
unsubSpawn: () => void;
|
||||
unsubStop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a single RPC handler: listen on `channel`, run `fn(params)`,
|
||||
* emit the reply envelope on `channel:reply:${requestId}`.
|
||||
*/
|
||||
function handleRpc<P extends { requestId: string }>(
|
||||
events: EventBus,
|
||||
channel: string,
|
||||
fn: (params: P) => unknown | Promise<unknown>,
|
||||
): () => void {
|
||||
return events.on(channel, async (raw: unknown) => {
|
||||
const params = raw as P;
|
||||
try {
|
||||
const data = await fn(params);
|
||||
const reply: { success: true; data?: unknown } = { success: true };
|
||||
if (data !== undefined) reply.data = data;
|
||||
events.emit(`${channel}:reply:${params.requestId}`, reply);
|
||||
} catch (err: any) {
|
||||
events.emit(`${channel}:reply:${params.requestId}`, {
|
||||
success: false, error: err?.message ?? String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register ping, spawn, and stop RPC handlers on the event bus.
|
||||
* Returns unsub functions for cleanup.
|
||||
*/
|
||||
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
||||
const { events, pi, getCtx, manager } = deps;
|
||||
|
||||
const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
|
||||
return { version: PROTOCOL_VERSION };
|
||||
});
|
||||
|
||||
const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
|
||||
events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) throw new Error("No active session");
|
||||
|
||||
// Cross-extension RPC callers (e.g. pi-tasks TaskExecute) naturally
|
||||
// forward serializable values, so options.model can be a string like
|
||||
// "openai-codex/gpt-5.5". Resolve it to a real Model instance here
|
||||
// — same pattern the scheduler path already uses — so the spawned
|
||||
// agent's auth lookup doesn't crash with "No API key found for
|
||||
// undefined".
|
||||
let normalizedOptions = options ?? {};
|
||||
if (typeof normalizedOptions.model === "string") {
|
||||
const registry = (ctx as { modelRegistry?: ModelRegistry }).modelRegistry;
|
||||
if (!registry) {
|
||||
throw new Error(
|
||||
`Model override "${normalizedOptions.model}" provided but ctx.modelRegistry is unavailable`,
|
||||
);
|
||||
}
|
||||
const resolved = resolveModel(normalizedOptions.model, registry);
|
||||
if (typeof resolved === "string") {
|
||||
// resolveModel returns a human-readable error string when the
|
||||
// input doesn't match any available model. Surface it instead of
|
||||
// silently falling back so the caller sees the auth/typo issue.
|
||||
throw new Error(resolved);
|
||||
}
|
||||
normalizedOptions = { ...normalizedOptions, model: resolved };
|
||||
}
|
||||
|
||||
return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) };
|
||||
},
|
||||
);
|
||||
|
||||
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
||||
events, "subagents:rpc:stop", ({ agentId }) => {
|
||||
if (!manager.abort(agentId)) throw new Error("Agent not found");
|
||||
},
|
||||
);
|
||||
|
||||
return { unsubPing, unsubSpawn, unsubStop };
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* custom-agents.ts — Load user-defined agents from project (.pi/agents/, plus the shared .agents/agents/ workspace) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
||||
import type { AgentConfig, IsolationMode, MemoryScope, ThinkingLevel } from "./types.js";
|
||||
|
||||
/**
|
||||
* The one thing a declared `name:` may not contain, matching Claude Code
|
||||
* exactly: it reserves `:` for plugin-scoped identifiers (`my-plugin:reviewer`)
|
||||
* and refuses to load a file whose name uses one.
|
||||
*
|
||||
* Nothing else is rejected. Claude Code's docs describe names as "lowercase
|
||||
* letters and hyphens", but that is guidance — the only stated load failure is
|
||||
* the colon, so `name: Code Reviewer` must work here too. (The stricter
|
||||
* letters/digits/underscore/hyphen regex in Claude Code applies to the Agent
|
||||
* tool's spawn-time `name` parameter, which is a different field.) Mixed case
|
||||
* has to be allowed regardless: the built-in types `Explore` and `Plan` use it,
|
||||
* and a file must be able to override one.
|
||||
*/
|
||||
const RESERVED_IN_TYPE = ":";
|
||||
|
||||
/**
|
||||
* Scan for custom agent .md files from multiple locations.
|
||||
* Discovery hierarchy (higher priority wins):
|
||||
* 1. Project: <cwd>/.pi/agents/*.md (authoritative — also where /agents writes)
|
||||
* 2. Workspace: <cwd>/.agents/agents/*.md (shared cross-tool .agents workspace, read-only)
|
||||
* 3. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
|
||||
*
|
||||
* Project-level agents override global ones with the same name. On a name clash
|
||||
* between the two project locations, .pi/agents wins — .pi stays the project
|
||||
* authority; .agents/agents is an additional read location.
|
||||
* Any name is allowed — names matching defaults (e.g. "Explore") override them.
|
||||
*
|
||||
* An agent's type comes from its frontmatter `name:`, falling back to the
|
||||
* filename — Claude Code's rule, where "the filename doesn't have to match".
|
||||
* Because the type is now declared rather than derived from a unique path, two
|
||||
* files can claim the same one; the later load wins, as it always has for a
|
||||
* filename clash, and `warnSkippedOverride` reports the substitution.
|
||||
*/
|
||||
export function loadCustomAgents(cwd: string, strict = false): Map<string, AgentConfig> {
|
||||
const globalDir = join(getAgentDir(), "agents");
|
||||
const workspaceProjectDir = join(cwd, ".agents", "agents");
|
||||
const projectDir = join(cwd, ".pi", "agents");
|
||||
|
||||
const agents = new Map<string, AgentConfig>();
|
||||
loadFromDir(globalDir, agents, "global", strict); // lowest priority
|
||||
loadFromDir(workspaceProjectDir, agents, "project", strict); // shared workspace
|
||||
loadFromDir(projectDir, agents, "project", strict); // highest priority (overwrites)
|
||||
|
||||
warnedLastLoad = warnedThisLoad;
|
||||
warnedThisLoad = new Set();
|
||||
return agents;
|
||||
}
|
||||
|
||||
/** Load agent configs from a directory into the map. */
|
||||
function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global", strict: boolean): void {
|
||||
if (!existsSync(dir)) return;
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir).filter(f => f.endsWith(".md"));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const filenameType = basename(file, ".md");
|
||||
|
||||
const path = join(dir, file);
|
||||
|
||||
const parsed = readAgentFile(path, strict);
|
||||
if (!parsed) {
|
||||
warnSkippedOverride(filenameType, agents);
|
||||
continue;
|
||||
}
|
||||
const { frontmatter: fm, body } = parsed;
|
||||
|
||||
// Claude Code's rule: `name:` IS the agent type, and the filename need not
|
||||
// match. Absent, the filename stands in — Claude Code requires the field,
|
||||
// but most files here predate it and must keep loading.
|
||||
const declared = str(fm.name)?.trim();
|
||||
if (declared?.includes(RESERVED_IN_TYPE)) {
|
||||
// Refusing beats silently substituting: the file would otherwise load
|
||||
// under its filename, so `Agent({subagent_type})` would succeed against
|
||||
// an agent whose declared identity nothing honoured.
|
||||
warnIfNew(
|
||||
`Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
|
||||
+ "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
|
||||
);
|
||||
// No `warnSkippedOverride`: this file would have registered under its
|
||||
// *declared* name, which nothing else can hold (a colon keeps it out of
|
||||
// the registry), so it shadowed nothing. Passing the filename instead
|
||||
// would report a substitution of an unrelated agent that never happened.
|
||||
continue;
|
||||
}
|
||||
// `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
|
||||
// register the agent under the empty type — unspawnable, and it takes the
|
||||
// filename-derived one down with it.
|
||||
const name = declared || filenameType;
|
||||
|
||||
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
|
||||
|
||||
agents.set(name, {
|
||||
name,
|
||||
// Only `display_name` now: `name` is the type, and `getConfig` already
|
||||
// falls back to the type when no label is set — so a Claude Code file
|
||||
// with `name: code-reviewer` still badges as "code-reviewer".
|
||||
displayName: str(fm.display_name),
|
||||
color: str(fm.color),
|
||||
description: str(fm.description) ?? name,
|
||||
builtinToolNames,
|
||||
extSelectors,
|
||||
disallowedTools: csvListOptional(fm.disallowed_tools),
|
||||
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
||||
excludeExtensions: csvListOptional(fm.exclude_extensions),
|
||||
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
||||
model: str(fm.model),
|
||||
thinking: str(fm.thinking) as ThinkingLevel | undefined,
|
||||
maxTurns: nonNegativeInt(fm.max_turns),
|
||||
persistSession: fm.persist_session != null ? fm.persist_session === true : undefined,
|
||||
outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
|
||||
sessionDir: str(fm.session_dir),
|
||||
allowedSubagents: parseAllowedSubagents(fm.allowed_subagents),
|
||||
systemPrompt: body.trim(),
|
||||
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
|
||||
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
|
||||
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
|
||||
isolated: fm.isolated != null ? fm.isolated === true : undefined,
|
||||
memory: parseMemory(fm.memory),
|
||||
isolation: parseIsolation(fm.isolation),
|
||||
enabled: fm.enabled !== false, // default true; explicitly false disables
|
||||
source,
|
||||
sourcePath: path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse one agent file, or warn and return undefined for the caller to
|
||||
* skip. One bad file must not take the whole extension down with it — an
|
||||
* unparseable `.md` used to abort activation, so pi exited before the TUI.
|
||||
*
|
||||
* The path is as much of the fix as the recovery: a bare YAML error ("line 2,
|
||||
* column 14") is unactionable when agents come from three directories at once,
|
||||
* and the only other symptom is `Unknown agent type`, which reads like a typo.
|
||||
*
|
||||
* Under `strict` the same failure rethrows, still naming the path, so callers
|
||||
* that opted into failing closed stop rather than run a substituted agent.
|
||||
*/
|
||||
function readAgentFile(path: string, strict: boolean): { frontmatter: Record<string, unknown>; body: string } | undefined {
|
||||
try {
|
||||
return parseFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8"));
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
if (strict) throw new Error(`${path}: ${reason}`);
|
||||
warnIfNew(`Skipping agent file ${path}: ${reason}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A skipped file that was overriding an already-loaded agent leaves the name
|
||||
* pointing at a *different* file — its own prompt, model and tools. Nothing
|
||||
* downstream can flag that: unlike an unknown type, the `Agent` call succeeds.
|
||||
*/
|
||||
function warnSkippedOverride(name: string, agents: Map<string, AgentConfig>): void {
|
||||
const surviving = agents.get(name);
|
||||
// Nothing shadowed, or what it shadowed is disabled: dispatch refuses the type
|
||||
// either way (see resolveEnabledTypeIn), so there is no substitution to report.
|
||||
if (!surviving?.sourcePath || surviving.enabled === false) return;
|
||||
warnIfNew(`Agent "${name}" now loads from ${surviving.sourcePath} instead`);
|
||||
}
|
||||
|
||||
let warnedLastLoad = new Set<string>();
|
||||
let warnedThisLoad = new Set<string>();
|
||||
|
||||
/**
|
||||
* Agents reload on activation and again on every `Agent` call, so an unchanged
|
||||
* problem would re-warn all session — over a painted TUI, since pi does not
|
||||
* redirect console output. Compare against the previous load rather than every
|
||||
* load ever, so a file that is fixed and then broken again still reports.
|
||||
*/
|
||||
function warnIfNew(message: string): void {
|
||||
warnedThisLoad.add(message);
|
||||
if (warnedLastLoad.has(message)) return;
|
||||
console.warn(`[pi-subagents] ${message}`);
|
||||
}
|
||||
|
||||
// ---- Field parsers ----
|
||||
// All follow the same convention: omitted → default, "none"/empty → nothing, value → exact.
|
||||
|
||||
/** Extract a string or undefined. */
|
||||
function str(val: unknown): string | undefined {
|
||||
return typeof val === "string" ? val : undefined;
|
||||
}
|
||||
|
||||
/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
|
||||
function nonNegativeInt(val: unknown): number | undefined {
|
||||
return typeof val === "number" && val >= 0 ? val : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw CSV field value into items, or undefined if absent/empty/"none".
|
||||
*/
|
||||
function parseCsvField(val: unknown): string[] | undefined {
|
||||
if (val === undefined || val === null) return undefined;
|
||||
const s = String(val).trim();
|
||||
if (!s || s === "none") return undefined;
|
||||
const items = s.split(",").map(t => t.trim()).filter(Boolean);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the nested-delegation allowlist. Single field, default-off:
|
||||
* omitted/empty/"none"/`false` → undefined (no nested tools); "all"/"*"/`true`
|
||||
* → "all" (any enabled agent); csv → only the listed types.
|
||||
*
|
||||
* Booleans are accepted because `extensions:`/`skills:` take them and users
|
||||
* generalize: without this, YAML's `true` stringifies into an agent type
|
||||
* literally named "true", so the tools appear and every spawn is refused.
|
||||
*/
|
||||
function parseAllowedSubagents(val: unknown): "all" | string[] | undefined {
|
||||
if (typeof val === "boolean") return val ? "all" : undefined;
|
||||
const items = parseCsvField(val);
|
||||
if (!items) return undefined;
|
||||
return items.some(i => i === "*" || i.toLowerCase() === "all") ? "all" : items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated list field with defaults.
|
||||
* omitted → defaults; "none"/empty → []; csv → listed items.
|
||||
*/
|
||||
function csvList(val: unknown, defaults: string[]): string[] {
|
||||
if (val === undefined || val === null) return defaults;
|
||||
return parseCsvField(val) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition the `tools:` CSV into the built-in tool allowlist and raw `ext:` selectors.
|
||||
* `*` (and the case-insensitive alias `all`, for `tools: all`) expands to all
|
||||
* built-ins; plain entries are built-in names; `ext:` entries are extension-tool
|
||||
* selectors parsed later by the runner. omitted → all built-ins, no selectors.
|
||||
* `tools:` present with only `ext:` entries → zero built-ins (use `*`).
|
||||
*/
|
||||
function parseToolsField(val: unknown): { builtinToolNames: string[]; extSelectors: string[] | undefined } {
|
||||
const entries = csvList(val, BUILTIN_TOOL_NAMES);
|
||||
const isWildcard = (e: string) => e === "*" || e.toLowerCase() === "all";
|
||||
const hasWildcard = entries.some(isWildcard);
|
||||
const plain = entries.filter(e => !isWildcard(e) && !e.startsWith("ext:"));
|
||||
const extEntries = entries.filter(e => e.startsWith("ext:"));
|
||||
return {
|
||||
builtinToolNames: hasWildcard ? [...new Set([...BUILTIN_TOOL_NAMES, ...plain])] : plain,
|
||||
extSelectors: extEntries.length > 0 ? extEntries : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an optional comma-separated list field.
|
||||
* omitted → undefined; "none"/empty → undefined; csv → listed items.
|
||||
*/
|
||||
function csvListOptional(val: unknown): string[] | undefined {
|
||||
return parseCsvField(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a memory scope field.
|
||||
* omitted → undefined; "user"/"project"/"local" → MemoryScope.
|
||||
*/
|
||||
function parseMemory(val: unknown): MemoryScope | undefined {
|
||||
if (val === "user" || val === "project" || val === "local") return val;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `isolation` frontmatter field.
|
||||
*
|
||||
* `off` is kept as a value rather than folded into `undefined` because the two
|
||||
* do not mean the same thing here: agent config outranks tool-call params, so
|
||||
* `off` vetoes a caller's `worktree` while an absent field lets it through.
|
||||
*
|
||||
* pi's frontmatter parser is not YAML 1.1 — bare `off` and `no` arrive as
|
||||
* strings and only `false` becomes a boolean — so all three spellings are
|
||||
* accepted rather than leaving an author's intent silently dropped. Anything
|
||||
* else stays `undefined`, as before.
|
||||
*/
|
||||
function parseIsolation(val: unknown): IsolationMode | undefined {
|
||||
if (val === "worktree") return "worktree";
|
||||
if (val === "off" || val === "none" || val === "no" || val === false) return "off";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an inherit field (extensions, skills).
|
||||
* omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names.
|
||||
*/
|
||||
function inheritField(val: unknown): true | string[] | false {
|
||||
if (val === undefined || val === null || val === true) return true;
|
||||
if (val === false || val === "none") return false;
|
||||
const items = csvList(val, []);
|
||||
return items.length > 0 ? items : false;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* default-agents.ts — Embedded default agent configurations.
|
||||
*
|
||||
* These are always available but can be overridden by user .md files with the same name.
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "./types.js";
|
||||
|
||||
const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"];
|
||||
|
||||
export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
||||
[
|
||||
"general-purpose",
|
||||
{
|
||||
name: "general-purpose",
|
||||
displayName: "Agent",
|
||||
description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
|
||||
// builtinToolNames omitted — means "all available tools" (resolved at lookup time)
|
||||
// inheritContext / runInBackground / isolated omitted — strategy fields, callers decide per-call.
|
||||
// Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
|
||||
extensions: true,
|
||||
skills: true,
|
||||
systemPrompt: "",
|
||||
promptMode: "append",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Explore",
|
||||
{
|
||||
name: "Explore",
|
||||
displayName: "Explore",
|
||||
description: "Fast read-only search agent for locating code. Use it to find files by pattern (eg. \"src/components/**/*.tsx\"), grep for symbols or keywords (eg. \"API endpoints\"), or answer \"where is X defined / which files reference Y.\" Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: \"quick\" for a single targeted lookup, \"medium\" for moderate exploration, or \"very thorough\" to search across multiple locations and naming conventions.",
|
||||
builtinToolNames: READ_ONLY_TOOLS,
|
||||
extensions: true,
|
||||
skills: true,
|
||||
// Fast/cheap model for read-only search. Provider-preferred but resilient:
|
||||
// resolveModel matches this fuzzily (date-stamp optional) and falls back to
|
||||
// the same model under another provider if anthropic doesn't expose it.
|
||||
model: "anthropic/claude-haiku-4-5",
|
||||
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
||||
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
|
||||
|
||||
You are STRICTLY PROHIBITED from:
|
||||
- Creating new files
|
||||
- Modifying existing files
|
||||
- Deleting files
|
||||
- Moving or copying files
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
|
||||
|
||||
# Tool Usage
|
||||
- Use the find tool for file pattern matching (NOT the bash find command)
|
||||
- Use the grep tool for content search (NOT bash grep/rg command)
|
||||
- Use the read tool for reading files (NOT bash cat/head/tail)
|
||||
- Use Bash ONLY for read-only operations
|
||||
- Make independent tool calls in parallel for efficiency
|
||||
- Adapt search approach based on thoroughness level specified
|
||||
|
||||
# Output
|
||||
- Use absolute file paths in all references
|
||||
- Report findings as regular messages
|
||||
- Do not use emojis
|
||||
- Be thorough and precise`,
|
||||
promptMode: "replace",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
"Plan",
|
||||
{
|
||||
name: "Plan",
|
||||
displayName: "Plan",
|
||||
description: "Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.",
|
||||
builtinToolNames: READ_ONLY_TOOLS,
|
||||
extensions: true,
|
||||
skills: true,
|
||||
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
||||
You are a software architect and planning specialist.
|
||||
Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
|
||||
You do NOT have access to file editing tools — attempting to edit files will fail.
|
||||
|
||||
You are STRICTLY PROHIBITED from:
|
||||
- Creating new files
|
||||
- Modifying existing files
|
||||
- Deleting files
|
||||
- Moving or copying files
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
# Planning Process
|
||||
1. Understand requirements
|
||||
2. Explore thoroughly (read files, find patterns, understand architecture)
|
||||
3. Design solution based on your assigned perspective
|
||||
4. Detail the plan with step-by-step implementation strategy
|
||||
|
||||
# Requirements
|
||||
- Consider trade-offs and architectural decisions
|
||||
- Identify dependencies and sequencing
|
||||
- Anticipate potential challenges
|
||||
- Follow existing patterns where appropriate
|
||||
|
||||
# Tool Usage
|
||||
- Use the find tool for file pattern matching (NOT the bash find command)
|
||||
- Use the grep tool for content search (NOT bash grep/rg command)
|
||||
- Use the read tool for reading files (NOT bash cat/head/tail)
|
||||
- Use Bash ONLY for read-only operations
|
||||
|
||||
# Output Format
|
||||
- Use absolute file paths
|
||||
- Do not use emojis
|
||||
- End your response with:
|
||||
|
||||
### Critical Files for Implementation
|
||||
List 3-5 files most critical for implementing this plan:
|
||||
- /absolute/path/to/file.ts - [Brief reason]`,
|
||||
promptMode: "replace",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Reads `enabledModels` from pi's settings (global `<agentDir>/settings.json`
|
||||
* + project-local `<cwd>/.pi/settings.json`, project wins) and resolves
|
||||
* entries to concrete `provider/modelId` keys for scope validation.
|
||||
*
|
||||
* **Project overrides global**, mirroring pi's own `SettingsManager`
|
||||
* deep-merge behavior and matching the precedence we use for our own
|
||||
* `subagents.json` settings (see `src/settings.ts:loadSettings`). If
|
||||
* project file has `enabledModels` set, it wholly replaces global's
|
||||
* (array fields are replaced, not concatenated).
|
||||
*
|
||||
* **Limited subset of upstream's resolveModelScope.** We support exact
|
||||
* `provider/modelId` matching only. Upstream (pi-coding-agent's
|
||||
* `core/model-resolver.ts`) additionally supports glob patterns
|
||||
* (`*sonnet*`, `anthropic/*`), bare model IDs without provider, and
|
||||
* thinking-level suffixes (`provider/*:high`). Those forms are silently
|
||||
* ignored here.
|
||||
*
|
||||
* In practice, pi's `/scoped-models` picker writes exact `provider/modelId`
|
||||
* entries, so the limitation is invisible for users who configure scope
|
||||
* through pi's UI. Hand-edited settings using globs or bare IDs will
|
||||
* produce an empty allowed set (scope check becomes a no-op).
|
||||
*
|
||||
* Example:
|
||||
* enabledModels = ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"]
|
||||
* → resolves to { "anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6" }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import type { ModelEntry } from "./model-resolver.js";
|
||||
|
||||
/** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
|
||||
export interface ModelRegistryRef {
|
||||
getAll(): unknown[];
|
||||
getAvailable?(): unknown[];
|
||||
}
|
||||
|
||||
/** Paths to pi's settings.json files: [project, global] (project takes precedence). */
|
||||
function settingsPaths(cwd: string): [project: string, global: string] {
|
||||
return [
|
||||
join(cwd, ".pi", "settings.json"),
|
||||
join(getAgentDir(), "settings.json"),
|
||||
];
|
||||
}
|
||||
|
||||
/** Read `enabledModels` from a single settings.json file. Undefined when missing or absent. */
|
||||
function readField(path: string): string[] | undefined {
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
||||
if (Array.isArray(raw?.enabledModels)) return raw.enabledModels as string[];
|
||||
} catch {
|
||||
/* corrupt file — silent */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read enabledModels from pi's settings — project-local overrides global.
|
||||
* Mirrors pi's SettingsManager deep-merge for the `enabledModels` field
|
||||
* (and matches our own loadSettings precedence in src/settings.ts).
|
||||
* Returns undefined when neither file has the field.
|
||||
*/
|
||||
export function readEnabledModels(cwd: string): string[] | undefined {
|
||||
const [project, global] = settingsPaths(cwd);
|
||||
return readField(project) ?? readField(global);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve enabledModels patterns → Set<"provider/modelId"> (lowercase keys).
|
||||
*
|
||||
* Only exact `provider/modelId` patterns are matched (case-insensitive).
|
||||
* Patterns without a slash, with glob characters, or with a `:thinking`
|
||||
* suffix are silently dropped. See module-level docstring for rationale.
|
||||
*
|
||||
* Cache: keyed on JSON.stringify(patterns) + mtime/size of *both*
|
||||
* project and global settings.json files. Re-resolves when either file
|
||||
* changes or the patterns argument differs.
|
||||
*
|
||||
* Returns undefined when no patterns are provided or no patterns match
|
||||
* (scope check becomes a no-op at the call site).
|
||||
*/
|
||||
|
||||
// Module-level cache — invalidated when either settings.json changes or patterns differ.
|
||||
let cachedAllowed: Set<string> | undefined;
|
||||
let cachedHash = "";
|
||||
let cachedPatternsKey = "";
|
||||
|
||||
/** mtime+size hash of one file, or "missing" if absent. */
|
||||
function hashOf(path: string): string {
|
||||
try {
|
||||
const s = statSync(path);
|
||||
return `${s.mtimeMs}-${s.size}`;
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEnabledModels(
|
||||
patterns: string[] | undefined,
|
||||
registry: ModelRegistryRef,
|
||||
cwd: string = process.cwd(),
|
||||
): Set<string> | undefined {
|
||||
// Fast path: check cache (stat both project and global settings.json files)
|
||||
const patternsKey = JSON.stringify(patterns);
|
||||
const [project, global] = settingsPaths(cwd);
|
||||
const fileHash = `${hashOf(project)};${hashOf(global)}`;
|
||||
|
||||
if (fileHash === cachedHash && patternsKey === cachedPatternsKey) {
|
||||
return cachedAllowed;
|
||||
}
|
||||
|
||||
// Cache miss — resolve
|
||||
if (!patterns || patterns.length === 0) {
|
||||
cachedHash = fileHash;
|
||||
cachedPatternsKey = patternsKey;
|
||||
cachedAllowed = undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const available = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
|
||||
const allowed = new Set<string>();
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) continue; // skip empty/whitespace
|
||||
resolveExact(trimmed, available, allowed);
|
||||
}
|
||||
|
||||
const result = allowed.size > 0 ? allowed : undefined;
|
||||
cachedHash = fileHash;
|
||||
cachedPatternsKey = patternsKey;
|
||||
cachedAllowed = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* True when `model` is in the allowed set. Centralizes the key format
|
||||
* (`provider/id` lowercase) so callers don't have to reproduce it —
|
||||
* both set-building (resolveExact) and lookup go through `modelKey`.
|
||||
*/
|
||||
export function isModelInScope(
|
||||
model: { provider: string; id: string },
|
||||
allowed: Set<string>,
|
||||
): boolean {
|
||||
return allowed.has(modelKey(model));
|
||||
}
|
||||
|
||||
/** Canonical lowercase `provider/id` key for the allowed set. */
|
||||
function modelKey(model: { provider: string; id: string }): string {
|
||||
return `${model.provider}/${model.id}`.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve exact model pattern. Example: "google/gemma-4-31b-it".
|
||||
*/
|
||||
function resolveExact(
|
||||
pattern: string,
|
||||
available: ModelEntry[],
|
||||
allowed: Set<string>,
|
||||
): void {
|
||||
// "provider/modelId" — exact (colon is part of id, not split)
|
||||
const slashIdx = pattern.indexOf("/");
|
||||
if (slashIdx === -1) return; // bare modelId not supported
|
||||
|
||||
const provider = pattern.slice(0, slashIdx).toLowerCase();
|
||||
const modelId = pattern.slice(slashIdx + 1).toLowerCase();
|
||||
const exact = available.find(
|
||||
m => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId,
|
||||
);
|
||||
if (exact) {
|
||||
allowed.add(modelKey(exact));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* env.ts — Detect environment info (git, platform) for subagent system prompts.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import type { EnvInfo } from "./types.js";
|
||||
|
||||
export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise<EnvInfo> {
|
||||
let isGitRepo = false;
|
||||
let branch = "";
|
||||
|
||||
try {
|
||||
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
|
||||
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
|
||||
} catch {
|
||||
// Not a git repo or git not installed
|
||||
}
|
||||
|
||||
if (isGitRepo) {
|
||||
try {
|
||||
const result = await pi.exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
|
||||
branch = result.code === 0 ? result.stdout.trim() : "unknown";
|
||||
} catch {
|
||||
branch = "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isGitRepo,
|
||||
branch,
|
||||
platform: process.platform,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* group-join.ts — Manages grouped background agent completion notifications.
|
||||
*
|
||||
* Instead of each agent individually nudging the main agent on completion,
|
||||
* agents in a group are held until all complete (or a timeout fires),
|
||||
* then a single consolidated notification is sent.
|
||||
*/
|
||||
|
||||
import type { AgentRecord } from "./types.js";
|
||||
|
||||
export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
|
||||
|
||||
interface AgentGroup {
|
||||
groupId: string;
|
||||
agentIds: Set<string>;
|
||||
completedRecords: Map<string, AgentRecord>;
|
||||
timeoutHandle?: ReturnType<typeof setTimeout>;
|
||||
delivered: boolean;
|
||||
/** Shorter timeout for stragglers after a partial delivery. */
|
||||
isStraggler: boolean;
|
||||
}
|
||||
|
||||
/** Default timeout: 30s after first completion in a group. */
|
||||
const DEFAULT_TIMEOUT = 30_000;
|
||||
/** Straggler re-batch timeout: 15s. */
|
||||
const STRAGGLER_TIMEOUT = 15_000;
|
||||
|
||||
export class GroupJoinManager {
|
||||
private groups = new Map<string, AgentGroup>();
|
||||
private agentToGroup = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private deliverCb: DeliveryCallback,
|
||||
private groupTimeout = DEFAULT_TIMEOUT,
|
||||
) {}
|
||||
|
||||
/** Register a group of agent IDs that should be joined. */
|
||||
registerGroup(groupId: string, agentIds: string[]): void {
|
||||
const group: AgentGroup = {
|
||||
groupId,
|
||||
agentIds: new Set(agentIds),
|
||||
completedRecords: new Map(),
|
||||
delivered: false,
|
||||
isStraggler: false,
|
||||
};
|
||||
this.groups.set(groupId, group);
|
||||
for (const id of agentIds) {
|
||||
this.agentToGroup.set(id, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an agent completes.
|
||||
* Returns:
|
||||
* - 'pass' — agent is not grouped, caller should send individual nudge
|
||||
* - 'held' — result held, waiting for group completion
|
||||
* - 'delivered' — this completion triggered the group notification
|
||||
*/
|
||||
onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
|
||||
const groupId = this.agentToGroup.get(record.id);
|
||||
if (!groupId) return 'pass';
|
||||
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group || group.delivered) return 'pass';
|
||||
|
||||
group.completedRecords.set(record.id, record);
|
||||
|
||||
// All done — deliver immediately
|
||||
if (group.completedRecords.size >= group.agentIds.size) {
|
||||
this.deliver(group, false);
|
||||
return 'delivered';
|
||||
}
|
||||
|
||||
// First completion in this batch — start timeout
|
||||
if (!group.timeoutHandle) {
|
||||
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
|
||||
group.timeoutHandle = setTimeout(() => {
|
||||
this.onTimeout(group);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
return 'held';
|
||||
}
|
||||
|
||||
private onTimeout(group: AgentGroup): void {
|
||||
if (group.delivered) return;
|
||||
group.timeoutHandle = undefined;
|
||||
|
||||
// Partial delivery — some agents still running
|
||||
const remaining = new Set<string>();
|
||||
for (const id of group.agentIds) {
|
||||
if (!group.completedRecords.has(id)) remaining.add(id);
|
||||
}
|
||||
|
||||
// Clean up agentToGroup for delivered agents (they won't complete again)
|
||||
for (const id of group.completedRecords.keys()) {
|
||||
this.agentToGroup.delete(id);
|
||||
}
|
||||
|
||||
// Deliver what we have
|
||||
this.deliverCb([...group.completedRecords.values()], true);
|
||||
|
||||
// Set up straggler group for remaining agents
|
||||
group.completedRecords.clear();
|
||||
group.agentIds = remaining;
|
||||
group.isStraggler = true;
|
||||
// Timeout will be started when the next straggler completes
|
||||
}
|
||||
|
||||
private deliver(group: AgentGroup, partial: boolean): void {
|
||||
if (group.timeoutHandle) {
|
||||
clearTimeout(group.timeoutHandle);
|
||||
group.timeoutHandle = undefined;
|
||||
}
|
||||
group.delivered = true;
|
||||
this.deliverCb([...group.completedRecords.values()], partial);
|
||||
this.cleanupGroup(group.groupId);
|
||||
}
|
||||
|
||||
private cleanupGroup(groupId: string): void {
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group) return;
|
||||
for (const id of group.agentIds) {
|
||||
this.agentToGroup.delete(id);
|
||||
}
|
||||
this.groups.delete(groupId);
|
||||
}
|
||||
|
||||
/** Check if an agent is in a group. */
|
||||
isGrouped(agentId: string): boolean {
|
||||
return this.agentToGroup.has(agentId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const group of this.groups.values()) {
|
||||
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
|
||||
}
|
||||
this.groups.clear();
|
||||
this.agentToGroup.clear();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
|
||||
|
||||
/**
|
||||
* The model-facing `isolation` parameter, shared by the `Agent` tool and the
|
||||
* nested delegation tool so the two cannot drift.
|
||||
*
|
||||
* Shape matters more than wording here. As a single-value optional literal,
|
||||
* models that fill every optional parameter — the transcript on #231 shows one
|
||||
* emitting `resume: ""`, `schedule: ""` and `model: "default"` alongside it —
|
||||
* had only `"worktree"` available to fill it with, and kept spawning worktrees
|
||||
* across three turns while their own reasoning said to omit the field. Every
|
||||
* other optional parameter has an inert filler; this one did not. `"off"` is
|
||||
* listed first and described as the default so the harmless value is the
|
||||
* obvious one to reach for.
|
||||
*
|
||||
* The wording tracks Claude Code's own `isolation` parameter, whose phrasing
|
||||
* models have the most exposure to: one description on the union rather than
|
||||
* per-value ones, opening "Isolation mode.", then a sentence per value in
|
||||
* schema order, each with its caveats in a trailing parenthetical. Two clauses
|
||||
* are ours, because our shape is not theirs — `"off"` has no counterpart there
|
||||
* (their enum is `worktree | remote`, so both of their values do something),
|
||||
* and neither does the uncommitted-work warning, which is the specific trap
|
||||
* #231 fell into. Deliberately absent is any "only use a worktree when…"
|
||||
* restriction: Claude Code's `Agent` tool states the capability and stops, and
|
||||
* a second legal value is what lets a model decline one, not being told to.
|
||||
*/
|
||||
const isolationParamShape = {
|
||||
isolation: Type.Optional(
|
||||
Type.Union([Type.Literal("off"), Type.Literal("worktree")], {
|
||||
description:
|
||||
'Isolation mode. Default "off". "off" runs the agent in the current checkout, the same as omitting the field. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo (a copy cannot see uncommitted or staged changes in the main checkout).',
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the `isolation` parameter for a tool schema, or nothing when the
|
||||
* project disabled worktrees (`worktreeIsolation: false`).
|
||||
*
|
||||
* Dropping the field beats accepting it and quietly downgrading. The setting is
|
||||
* for a project whose model passes `"worktree"` on *every* call, so a
|
||||
* per-result "isolation was disabled" note would be noise on every result and
|
||||
* would keep raising the salience of a capability that isn't there. With no
|
||||
* field there is nothing to pass, nothing to drop, and nothing to explain — the
|
||||
* same trade `scheduleParam` makes for disabled scheduling, at zero LLM-context
|
||||
* cost. The resolver gate and the `agent-manager` check still cover the paths a
|
||||
* schema can't reach: agent files, the scheduler, and cross-extension RPC.
|
||||
*
|
||||
* Like `scheduleParam`, this is read once at tool registration — flipping the
|
||||
* setting needs a new pi session for the schema to change.
|
||||
*/
|
||||
export function isolationParam(enabled: boolean): Partial<typeof isolationParamShape> {
|
||||
return enabled ? isolationParamShape : {};
|
||||
}
|
||||
|
||||
interface AgentInvocationParams {
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
max_turns?: number;
|
||||
run_in_background?: boolean;
|
||||
inherit_context?: boolean;
|
||||
isolated?: boolean;
|
||||
/**
|
||||
* Untyped on purpose. Both tool schemas now build this field conditionally
|
||||
* and spread it, which erases TypeBox's literal inference to `unknown` (the
|
||||
* `schedule` param has the same shape). The resolver below narrows by
|
||||
* comparison rather than trusting the declaration, which also makes it safe
|
||||
* for the cross-extension RPC path, where options arrive unvalidated.
|
||||
*/
|
||||
isolation?: unknown;
|
||||
}
|
||||
|
||||
interface ResolveOptions {
|
||||
/**
|
||||
* Whether worktree isolation is permitted at all. False when the project set
|
||||
* `worktreeIsolation: false`, which drops a requested worktree rather than
|
||||
* failing the call: the fail-loud precedent covers spawns that *cannot* work,
|
||||
* while this one is the user opting out, and throwing would break exactly the
|
||||
* calls the `"off"` value exists to tolerate. Defaults to allowed.
|
||||
*/
|
||||
worktreeAllowed?: boolean;
|
||||
/**
|
||||
* What an unqualified spawn means — neither the call nor the agent file said.
|
||||
*
|
||||
* Top-level callers pass the `backgroundByDefault` setting (default `true`,
|
||||
* following Claude Code). Nested callers pass `false` unconditionally: a
|
||||
* detached child is killed by `abortOwnedChildren` when its parent settles
|
||||
* and has no notification path of its own, so backgrounding one loses its
|
||||
* work. Both call sites pass it explicitly; the `false` fallback only covers
|
||||
* a caller that supplies no options at all, which in-tree means tests.
|
||||
*/
|
||||
defaultRunInBackground?: boolean;
|
||||
}
|
||||
|
||||
export function resolveAgentInvocationConfig(
|
||||
agentConfig: AgentConfig | undefined,
|
||||
params: AgentInvocationParams,
|
||||
opts?: ResolveOptions,
|
||||
): {
|
||||
modelInput?: string;
|
||||
modelFromParams: boolean;
|
||||
thinking?: ThinkingLevel;
|
||||
maxTurns?: number;
|
||||
inheritContext: boolean;
|
||||
runInBackground: boolean;
|
||||
isolated: boolean;
|
||||
isolation?: IsolationMode;
|
||||
} {
|
||||
// Precedence first, collapse second — reversing these loses the veto, since
|
||||
// an agent file's "off" only outranks a caller's "worktree" while it is still
|
||||
// a value. Everything downstream then sees "worktree" or nothing at all.
|
||||
const requested = agentConfig?.isolation ?? params.isolation;
|
||||
const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined;
|
||||
|
||||
return {
|
||||
modelInput: agentConfig?.model ?? params.model,
|
||||
modelFromParams: agentConfig?.model == null && params.model != null,
|
||||
thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
|
||||
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
|
||||
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
||||
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? opts?.defaultRunInBackground ?? false,
|
||||
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
||||
isolation,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
|
||||
return runInBackground ? defaultJoinMode : undefined;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { realpathSync, statSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
export interface MandatoryExtensionPolicy {
|
||||
/** Exact, bundle-owned extension entry paths that an agent cannot disable. */
|
||||
mandatoryExtensionPaths?: readonly string[];
|
||||
}
|
||||
|
||||
function canonicalExistingPath(path: string): string {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new Error(`Mandatory extension path must be absolute: ${path}`);
|
||||
}
|
||||
let canonical: string;
|
||||
try {
|
||||
canonical = realpathSync.native(path);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Mandatory extension path is unavailable: ${path} (${message})`);
|
||||
}
|
||||
const stat = statSync(canonical);
|
||||
if (!stat.isFile() && !stat.isDirectory()) {
|
||||
throw new Error(`Mandatory extension path is not a file or directory: ${path}`);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/** Validate, canonicalize, and deduplicate bundle-owned extension entry paths. */
|
||||
export function normalizeMandatoryExtensionPaths(paths: readonly string[] | undefined): readonly string[] {
|
||||
if (!paths?.length) return [];
|
||||
return Object.freeze([...new Set(paths.map(canonicalExistingPath))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare loader-reported paths to a canonical mandatory entry. Loader paths
|
||||
* normally exist, but resolve-only fallback keeps diagnostics deterministic if
|
||||
* an extension disappears between reload and verification.
|
||||
*/
|
||||
export function extensionPathMatches(actualPath: string, canonicalExpectedPath: string): boolean {
|
||||
let actual: string;
|
||||
try {
|
||||
actual = realpathSync.native(actualPath);
|
||||
} catch {
|
||||
actual = resolve(actualPath);
|
||||
}
|
||||
return actual === canonicalExpectedPath;
|
||||
}
|
||||
|
||||
export function isMandatoryExtensionPath(
|
||||
extensionPath: string,
|
||||
mandatoryExtensionPaths: readonly string[],
|
||||
): boolean {
|
||||
return mandatoryExtensionPaths.some((path) => extensionPathMatches(extensionPath, path));
|
||||
}
|
||||
|
||||
/** Mandatory infrastructure is useful only if the exact trusted entry survived reload. */
|
||||
export function assertMandatoryExtensionsLoaded(
|
||||
loadedExtensionPaths: readonly string[],
|
||||
mandatoryExtensionPaths: readonly string[],
|
||||
): void {
|
||||
for (const mandatoryPath of mandatoryExtensionPaths) {
|
||||
if (!loadedExtensionPaths.some((loadedPath) => extensionPathMatches(loadedPath, mandatoryPath))) {
|
||||
throw new Error(`Mandatory extension failed to load: ${mandatoryPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
|
||||
*
|
||||
* Memory scopes:
|
||||
* - "user" → getAgentDir()/agent-memory/{agent-name}/ (default ~/.pi/agent/agent-memory/, honors $PI_CODING_AGENT_DIR)
|
||||
* - "project" → .pi/agent-memory/{agent-name}/
|
||||
* - "local" → .pi/agent-memory-local/{agent-name}/
|
||||
*
|
||||
* The user scope previously hardcoded ~/.pi/agent-memory/. That legacy location
|
||||
* is still honored (read + write) when it exists and the new location doesn't,
|
||||
* so existing memories aren't orphaned.
|
||||
*/
|
||||
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, } from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import type { MemoryScope } from "./types.js";
|
||||
|
||||
/** Maximum lines to read from MEMORY.md */
|
||||
const MAX_MEMORY_LINES = 200;
|
||||
|
||||
/**
|
||||
* Returns true if a name contains characters not allowed in agent/skill names.
|
||||
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
|
||||
*/
|
||||
export function isUnsafeName(name: string): boolean {
|
||||
if (!name || name.length > 128) return true;
|
||||
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given path is a symlink (defense against symlink attacks).
|
||||
*/
|
||||
export function isSymlink(filePath: string): boolean {
|
||||
try {
|
||||
return lstatSync(filePath).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read a file, rejecting symlinks.
|
||||
* Returns undefined if the file doesn't exist, is a symlink, or can't be read.
|
||||
*/
|
||||
export function safeReadFile(filePath: string): string | undefined {
|
||||
if (!existsSync(filePath)) return undefined;
|
||||
if (isSymlink(filePath)) return undefined;
|
||||
try {
|
||||
return readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the memory directory path for a given agent + scope + cwd.
|
||||
* Throws if agentName contains path traversal characters.
|
||||
*/
|
||||
export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
|
||||
if (isUnsafeName(agentName)) {
|
||||
throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
|
||||
}
|
||||
switch (scope) {
|
||||
case "user": {
|
||||
const current = join(getAgentDir(), "agent-memory", agentName);
|
||||
// Legacy location from when this path was hardcoded. Keep using it if it
|
||||
// already holds this agent's memory and the new location hasn't been
|
||||
// created yet — otherwise existing memories would be silently orphaned.
|
||||
const legacy = join(homedir(), ".pi", "agent-memory", agentName);
|
||||
if (!existsSync(current) && existsSync(legacy) && !isSymlink(legacy)) {
|
||||
return legacy;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
case "project":
|
||||
return join(cwd, ".pi", "agent-memory", agentName);
|
||||
case "local":
|
||||
return join(cwd, ".pi", "agent-memory-local", agentName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the memory directory exists, creating it if needed.
|
||||
* Refuses to create directories if any component in the path is a symlink
|
||||
* to prevent symlink-based directory traversal attacks.
|
||||
*/
|
||||
export function ensureMemoryDir(memoryDir: string): void {
|
||||
// If the directory already exists, verify it's not a symlink
|
||||
if (existsSync(memoryDir)) {
|
||||
if (isSymlink(memoryDir)) {
|
||||
throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first N lines of MEMORY.md from the memory directory, if it exists.
|
||||
* Returns undefined if no MEMORY.md exists or if the path is a symlink.
|
||||
*/
|
||||
export function readMemoryIndex(memoryDir: string): string | undefined {
|
||||
// Reject symlinked memory directories
|
||||
if (isSymlink(memoryDir)) return undefined;
|
||||
|
||||
const memoryFile = join(memoryDir, "MEMORY.md");
|
||||
const content = safeReadFile(memoryFile);
|
||||
if (content === undefined) return undefined;
|
||||
|
||||
const lines = content.split("\n");
|
||||
if (lines.length > MAX_MEMORY_LINES) {
|
||||
return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)";
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the memory block to inject into the agent's system prompt.
|
||||
* Also ensures the memory directory exists (creates it if needed).
|
||||
*/
|
||||
export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
||||
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
||||
// Create the memory directory so the agent can immediately write to it
|
||||
ensureMemoryDir(memoryDir);
|
||||
|
||||
const existingMemory = readMemoryIndex(memoryDir);
|
||||
|
||||
const header = `# Agent Memory
|
||||
|
||||
You have a persistent memory directory at: ${memoryDir}/
|
||||
Memory scope: ${scope}
|
||||
|
||||
This memory persists across sessions. Use it to build up knowledge over time.`;
|
||||
|
||||
const memoryContent = existingMemory
|
||||
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
||||
: `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`;
|
||||
|
||||
const instructions = `
|
||||
|
||||
## Memory Instructions
|
||||
- MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated.
|
||||
- Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md.
|
||||
- Each memory file should use this frontmatter format:
|
||||
\`\`\`markdown
|
||||
---
|
||||
name: <memory name>
|
||||
description: <one-line description>
|
||||
type: <user|feedback|project|reference>
|
||||
---
|
||||
<memory content>
|
||||
\`\`\`
|
||||
- Update or remove memories that become outdated. Check for existing memories before creating duplicates.
|
||||
- You have Read, Write, and Edit tools available for managing memory files.`;
|
||||
|
||||
return header + memoryContent + instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a read-only memory block for agents that lack write/edit tools.
|
||||
* Does NOT create the memory directory — agents can only consume existing memory.
|
||||
*/
|
||||
export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
||||
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
||||
const existingMemory = readMemoryIndex(memoryDir);
|
||||
|
||||
const header = `# Agent Memory (read-only)
|
||||
|
||||
Memory scope: ${scope}
|
||||
You have read-only access to memory. You can reference existing memories but cannot create or modify them.`;
|
||||
|
||||
const memoryContent = existingMemory
|
||||
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
||||
: `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`;
|
||||
|
||||
return header + memoryContent;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* mention-clone.ts — start a mentioned agent through a clone of this
|
||||
* conversation, without putting anything in the chat.
|
||||
*
|
||||
* Claude Code routes `@agent-<type>` through the main model: the mention
|
||||
* becomes a `<system-reminder>` appended to the prompt and the model makes the
|
||||
* tool call (see `agentMentionReminder`). That buys the spawned agent a prompt
|
||||
* written with conversation context, and costs a visible turn — the model's
|
||||
* reasoning and its tool block land in the transcript, for a decision the user
|
||||
* already made when they typed the handle.
|
||||
*
|
||||
* So the turn happens somewhere else. The conversation is cloned into a
|
||||
* throwaway in-memory session — same messages, same system prompt, same model —
|
||||
* and that copy takes the turn off-screen. A literal clone: the session's own
|
||||
* entries, projected by pi's own `sessionEntryToContextMessages`, not
|
||||
* `inherit_context`'s text rendering of them.
|
||||
*
|
||||
* Cloned from memory rather than from the session file, which cannot be relied
|
||||
* on: `SessionManager._persist` withholds every write until the first assistant
|
||||
* message lands, so a fork taken before then reads an empty file and throws.
|
||||
* `buildSessionContext()` has no such timing, and is compaction-aware — it walks
|
||||
* the leaf path and substitutes the summary for entries folded into it, so a
|
||||
* long conversation clones as what the main model is actually working from. A
|
||||
* conversation with nothing in it yet clones to nothing in it yet, which is the
|
||||
* correct answer rather than a failure.
|
||||
*
|
||||
* It is also the oldest of the equivalent Pi APIs — `buildContextEntries` on
|
||||
* ReadonlySessionManager and the `sessionEntryToContextMessages` export both
|
||||
* arrived in 0.80.5 — where this one has been exported unchanged from before
|
||||
* the declared peer floor, and is the same code path (`byId` is only an index
|
||||
* cache, so passing it or not cannot change the result). Keeping the floor
|
||||
* honest costs nothing here: see the `compat-floor-pi` job.
|
||||
*
|
||||
* Its `thinkingLevel` is NOT used, and is the one place the newer API would be
|
||||
* better. `getSessionContextSettings` starts at "off" and moves only on an
|
||||
* explicit `thinking_level_change` entry, so a session where nobody ran
|
||||
* `/think` reports "off" rather than the level it is really using. Omitting the
|
||||
* field instead lets `createAgentSession` resolve it from settings, which is
|
||||
* that real level.
|
||||
*
|
||||
* Three details make the spawn belong to the real session rather than the
|
||||
* clone:
|
||||
*
|
||||
* - the clone is handed the *registered* `Agent` tool, whose handler closes
|
||||
* over the main activation, so it spawns top-level: widget, fleet row,
|
||||
* handle, completion notification, all as if the main model had called it;
|
||||
* - that tool is re-bound to the main `ExtensionContext`, because the handler
|
||||
* reads `cwd`, `model` and `sessionManager.getSessionId()` off it to place
|
||||
* the transcript and the `rootSessionId`. The clone's own context would
|
||||
* file both under the throwaway fork;
|
||||
* - it is called with no tool-call id. The clone's turn produces one, but the
|
||||
* real session never issued it, and a `<tool-use-id>` pointing at nothing
|
||||
* is exactly the bug the mention-resume path had to fix;
|
||||
* - and it is forced into the background. A foreground agent returns its
|
||||
* answer as the tool result and is marked `resultConsumed` so no completion
|
||||
* notification is sent — correct when the caller is the real conversation,
|
||||
* silent loss when the caller is a fork about to be discarded. Background
|
||||
* delivery is the only route from a mention back to the main model.
|
||||
*
|
||||
* The clone gets one tool and one job. It cannot read, write or run anything —
|
||||
* an invisible turn with the full toolset could do invisible work.
|
||||
*/
|
||||
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
buildSessionContext,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
type ExtensionContext,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
type ToolDefinition,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { runInChildSessionContext } from "./child-context.js";
|
||||
import { agentMentionReminder } from "./mention.js";
|
||||
import type { SubagentType, ThinkingLevel } from "./types.js";
|
||||
|
||||
export interface MentionCloneOptions {
|
||||
/** The MAIN session's context — what the spawn is attributed to, and the
|
||||
* source of both the conversation and the live system prompt. */
|
||||
ctx: ExtensionContext;
|
||||
/** Agent type the handle resolved to. */
|
||||
type: SubagentType;
|
||||
/** What the user typed after the handle. */
|
||||
message: string;
|
||||
/** The registered `Agent` tool, reused so the spawn is an ordinary one. */
|
||||
agentTool: ToolDefinition;
|
||||
}
|
||||
|
||||
export interface MentionCloneResult {
|
||||
/** True once the clone actually called `Agent`. */
|
||||
spawned: boolean;
|
||||
/** Why not, when it didn't. Absent on success. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the conversation, let the copy make the tool call, throw the copy away.
|
||||
* Never rejects: a clone that cannot run is reported so the caller can fall
|
||||
* back to starting the agent directly.
|
||||
*/
|
||||
export async function runMentionClone(opts: MentionCloneOptions): Promise<MentionCloneResult> {
|
||||
const { ctx, type, message, agentTool } = opts;
|
||||
|
||||
let spawned = false;
|
||||
const cloneAgentTool: ToolDefinition = {
|
||||
...agentTool,
|
||||
execute: (_cloneToolCallId, params, signal, onUpdate, _cloneCtx) => {
|
||||
// One spawn per mention. The clone has a single tool and every reason to
|
||||
// stop after using it, but a model that decides to "also" launch a second
|
||||
// agent would do it where nobody can see and nobody asked.
|
||||
if (spawned) {
|
||||
return Promise.resolve({
|
||||
content: [{ type: "text" as const, text: "Already started an agent for this mention. Stop here." }],
|
||||
details: undefined,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
spawned = true;
|
||||
// undefined tool-call id + the main ctx: see the header. Background is
|
||||
// forced rather than left to the clone: `run_in_background` defaults to
|
||||
// false, and a foreground agent answers through its TOOL RESULT — which
|
||||
// here is delivered into a session that is disposed moments later, so the
|
||||
// agent would run, appear in the widget and the fleet, and reach nobody.
|
||||
return agentTool.execute(
|
||||
undefined as never,
|
||||
{ ...(params as Record<string, unknown>), run_in_background: true } as typeof params,
|
||||
signal,
|
||||
onUpdate,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
||||
try {
|
||||
// Pi 0.80.8 moved createAgentSession from modelRegistry to modelRuntime;
|
||||
// agent-runner.ts carries the same shim for the same reason — pass both so
|
||||
// the clone keeps the parent's providers across the supported range.
|
||||
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
|
||||
// The conversation as the main session resolves it: compaction applied,
|
||||
// branch summaries substituted.
|
||||
const conversation = buildSessionContext(
|
||||
ctx.sessionManager.getEntries(),
|
||||
ctx.sessionManager.getLeafId(),
|
||||
);
|
||||
// Pi 0.82.0 added this; below it the field is absent and the clone takes
|
||||
// the settings level instead, which is what a session that never ran
|
||||
// `/think` is on anyway. Same shim shape as `modelRuntime` below.
|
||||
const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel;
|
||||
// This off-screen clone must expose only its synthetic Agent tool. Loading
|
||||
// host/project extensions would let active-tool owners such as Tool Search
|
||||
// replace the clone's allowlist during session_start, and would also run
|
||||
// unrelated extension lifecycle in a session the user never sees.
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: ctx.cwd,
|
||||
agentDir: getAgentDir(),
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
});
|
||||
const created = await runInChildSessionContext(() =>
|
||||
createAgentSession({
|
||||
cwd: ctx.cwd,
|
||||
// Nothing about the copy is worth persisting, and an in-memory manager
|
||||
// is also what keeps the real session untouched.
|
||||
sessionManager: SessionManager.inMemory(ctx.cwd),
|
||||
resourceLoader,
|
||||
model: ctx.model as Model<never> | undefined,
|
||||
...(thinkingLevel && { thinkingLevel }),
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }),
|
||||
// An allowlist naming exactly the clone's own tool. NOT `noTools:
|
||||
// "all"`, whose doc comment ("start with no tools enabled") reads like
|
||||
// it spares custom tools and does not: it resolves to an EMPTY
|
||||
// allowlist, and `isAllowedTool` then drops every tool from the
|
||||
// registry — the custom one included. The clone would be prompted with
|
||||
// nothing to call, answer in prose, and every mention would fall
|
||||
// through to the direct start with a warning. Same idiom as
|
||||
// agent-runner's `tools: sessionTools` beside its nested `customTools`.
|
||||
tools: [cloneAgentTool.name],
|
||||
customTools: [cloneAgentTool],
|
||||
} as Parameters<typeof createAgentSession>[0]),
|
||||
);
|
||||
session = created.session;
|
||||
|
||||
// The clone rebuilds a system prompt from cwd and agentDir, which is close
|
||||
// but not the live one — extensions contribute to it per turn. Copy the
|
||||
// real thing, so the copy reasons under the instructions the user's model
|
||||
// is actually working under.
|
||||
const systemPrompt = ctx.getSystemPrompt?.();
|
||||
if (systemPrompt) session.agent.state.systemPrompt = systemPrompt;
|
||||
|
||||
// The conversation itself. Pushed rather than assigned so the array the
|
||||
// session was built around stays the one it goes on using.
|
||||
session.agent.state.messages.push(...conversation.messages);
|
||||
|
||||
// User text first, reminder after — the order Claude Code's attachment
|
||||
// renderer produces, where the reminder trails the message it is about.
|
||||
await session.prompt(`${message}\n\n${agentMentionReminder(type)}`);
|
||||
} catch (err) {
|
||||
return { spawned, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
session?.dispose?.();
|
||||
}
|
||||
|
||||
return spawned
|
||||
? { spawned: true }
|
||||
: { spawned: false, error: "the conversation clone did not start it" };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* mention.ts — the `@handle` grammar for messaging a subagent from the prompt.
|
||||
*
|
||||
* Claude Code lets you type `@code-review take another look` at the prompt and
|
||||
* routes the message to that agent instead of the main model. Its grammar is
|
||||
* reproduced here so the two behave identically:
|
||||
*
|
||||
* - suggestions fire on `@` at the start of the input or after whitespace,
|
||||
* followed by `[\w-]*` (so `@src/foo.ts` is a file, never an agent);
|
||||
* - a send is recognized only at the START of the input, and only with a
|
||||
* non-empty message after the handle. That is why a bare `@code-review`
|
||||
* goes to the main model rather than anywhere near the agent.
|
||||
*
|
||||
* A record's own identity is a UUID plus a deliberately non-unique description,
|
||||
* neither of which is typeable, so the handle is derived from the agent type.
|
||||
* Colliding handles are numbered (`explore`, `explore-2`), which is also what
|
||||
* Claude Code's `allocateName` does — it recycles a name only once the task
|
||||
* behind it is gone. Its SendMessage prompt describes the *registry* as
|
||||
* latest-wins, which is a different thing and not how names are allocated.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Suggestion trigger: `@` at a token boundary plus the partial handle typed so
|
||||
* far. Ported from Claude Code, including the CJK sentence-ending punctuation
|
||||
* it accepts as a boundary.
|
||||
*/
|
||||
export const MENTION_TRIGGER = /(^|[\s。、?!])@([\w-]*)$/;
|
||||
|
||||
/** Send grammar: leading `@handle`, then a non-empty message. */
|
||||
const MENTION_SEND = /^@([\w-]+)\s+([\s\S]+)$/;
|
||||
|
||||
/**
|
||||
* Upper bound on a handle, matching Claude Code's `dSS`. Nothing here generates
|
||||
* a name this long, but an agent type or a model-supplied name can be arbitrary
|
||||
* text, and an unbounded handle would wrap the suggestion popup.
|
||||
*/
|
||||
const MAX_HANDLE_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Handles that address something other than a subagent, and so can never be
|
||||
* allocated to one. Claude Code reserves exactly this name (`Vq = "main"`),
|
||||
* refusing it at spawn and routing it to the main conversation instead.
|
||||
*/
|
||||
const RESERVED_HANDLES: ReadonlySet<string> = new Set(["main"]);
|
||||
|
||||
/** Whether `@handle` names the main conversation rather than any subagent. */
|
||||
export function isReservedHandle(handle: string): boolean {
|
||||
return RESERVED_HANDLES.has(handle.toLowerCase());
|
||||
}
|
||||
|
||||
/** Slug of an agent type or name, restricted to the `[\w-]` the grammar allows. */
|
||||
export function handleBase(type: string): string {
|
||||
const slug = type.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, MAX_HANDLE_LENGTH)
|
||||
// The slice can land mid-run and leave the trailing hyphen back.
|
||||
.replace(/-+$/, "");
|
||||
return slug || "agent";
|
||||
}
|
||||
|
||||
/**
|
||||
* `base`, else `base-2`, `base-3`, … — the first form that is neither `taken`
|
||||
* nor reserved. Callers pass one shared `taken` set covering type-derived
|
||||
* handles and model-supplied aliases alike, so the two can never collide.
|
||||
*/
|
||||
export function assignHandle(base: string, taken: ReadonlySet<string>): string {
|
||||
let candidate = base;
|
||||
let n = 1;
|
||||
while (taken.has(candidate) || RESERVED_HANDLES.has(candidate)) {
|
||||
n++;
|
||||
candidate = `${base}-${n}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a typed handle back to a registered agent type, so `@explore fix it`
|
||||
* reaches the Explore agent even when no instance has ever run. `handleBase` is
|
||||
* the single source of truth in both directions, so a type is addressable by
|
||||
* exactly the handle its instances would be given.
|
||||
*/
|
||||
export function resolveHandleToType(handle: string, types: readonly string[]): string | undefined {
|
||||
const wanted = handle.toLowerCase();
|
||||
// A type slugging to a reserved name is unaddressable rather than shadowing
|
||||
// it — `assignHandle` refuses that name too, so its instances never hold one.
|
||||
if (RESERVED_HANDLES.has(wanted)) return undefined;
|
||||
return types.find(type => handleBase(type) === wanted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code documents `@agent-<name>` as the form you type by hand when the
|
||||
* picker isn't involved. Accepted here as an exact synonym: the caller tries the
|
||||
* handle as written first, so an agent genuinely called `agent-foo` still wins
|
||||
* over `@agent-` + `foo`, and only falls back to this when that finds nothing.
|
||||
* Returns undefined when the prefix is absent or is the whole handle.
|
||||
*/
|
||||
export function stripAgentPrefix(handle: string): string | undefined {
|
||||
const rest = /^agent-(.+)$/i.exec(handle)?.[1];
|
||||
return rest || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A spawn needs the short description every agent surface renders. A mention
|
||||
* carries no separate label, so the message itself becomes one: first line,
|
||||
* whitespace collapsed, clipped to roughly the 3-5 words the Agent tool asks of
|
||||
* the model.
|
||||
*/
|
||||
export function describeMention(message: string): string {
|
||||
const oneLine = message.split("\n", 1)[0].replace(/\s+/g, " ").trim();
|
||||
return oneLine.length > 40 ? `${oneLine.slice(0, 39).trimEnd()}…` : oneLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Claude Code sends the main model when a mention names an agent it could
|
||||
* start. Its `@agent-<type>` mention is not a spawn at all: it becomes an
|
||||
* `agent_mention` attachment, which renders to a synthetic `isMeta` user
|
||||
* message placed after the user's own untouched text — no tool forcing, no
|
||||
* allowed-tools narrowing, and the Task tool is not even named. The model reads
|
||||
* this and calls the tool itself.
|
||||
*
|
||||
* Ported verbatim from the 2.1.233 bundle's attachment renderer, trailing space
|
||||
* before the closing newline included, so the wording the model was trained
|
||||
* against is the wording it gets. The one substitution is ours: pi's equivalent
|
||||
* of Task is the `Agent` tool, and the agent listing that teaches valid
|
||||
* `subagent_type` values is the tool spec rather than a separate attachment.
|
||||
*/
|
||||
export function agentMentionReminder(type: string): string {
|
||||
return `<system-reminder>\nThe user has expressed a desire to invoke the agent "${type}". Please invoke the agent appropriately, passing in the required context to it. \n</system-reminder>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `@handle message` into its parts, or null when the text isn't a send —
|
||||
* a bare handle, a leading file path, or a mention that isn't at the start.
|
||||
*/
|
||||
export function parseMention(text: string): { handle: string; message: string } | null {
|
||||
const match = MENTION_SEND.exec(text);
|
||||
if (!match) return null;
|
||||
const message = match[2].trim();
|
||||
return message ? { handle: match[1], message } : null;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
||||
*/
|
||||
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface ModelRegistry {
|
||||
find(provider: string, modelId: string): any;
|
||||
getAll(): any[];
|
||||
getAvailable?(): any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model string to a Model instance.
|
||||
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
|
||||
* Returns the Model on success, or an error message string on failure.
|
||||
*/
|
||||
export function resolveModel(
|
||||
input: string,
|
||||
registry: ModelRegistry,
|
||||
): any | string {
|
||||
// Available models (those with auth configured)
|
||||
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
|
||||
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
|
||||
|
||||
// 1. Exact match: "provider/modelId" — only if available (has auth)
|
||||
const slashIdx = input.indexOf("/");
|
||||
if (slashIdx !== -1) {
|
||||
const provider = input.slice(0, slashIdx);
|
||||
const modelId = input.slice(slashIdx + 1);
|
||||
if (availableSet.has(input.toLowerCase())) {
|
||||
const found = registry.find(provider, modelId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fuzzy match against available models. Normalize separators so cosmetic
|
||||
// punctuation differences still match — e.g. "claude-haiku-4.5" and
|
||||
// "claude-haiku-4-5" (dot vs dash in the version) resolve to the same model.
|
||||
const normalize = (s: string) => s.toLowerCase().replace(/\./g, "-");
|
||||
const query = normalize(input);
|
||||
|
||||
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
|
||||
let bestMatch: ModelEntry | undefined;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const m of all) {
|
||||
const id = normalize(m.id);
|
||||
const name = normalize(m.name);
|
||||
const full = normalize(`${m.provider}/${m.id}`);
|
||||
|
||||
let score = 0;
|
||||
if (id === query || full === query) {
|
||||
score = 100; // exact
|
||||
} else if (id.includes(query) || full.includes(query)) {
|
||||
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
|
||||
} else if (name.includes(query)) {
|
||||
score = 40 + (query.length / name.length) * 20;
|
||||
} else if (
|
||||
// A trailing date-stamp token (e.g. "20251001") is optional, so a
|
||||
// date-pinned config like "claude-haiku-4-5-20251001" still matches an
|
||||
// undated registry id like "claude-haiku-4-5".
|
||||
query
|
||||
.split(/[\s\-/]+/)
|
||||
.every(part => /^\d{8}$/.test(part) || id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))
|
||||
) {
|
||||
score = 20; // all parts present somewhere
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = m;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && bestScore >= 20) {
|
||||
const found = registry.find(bestMatch.provider, bestMatch.id);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// 3. Provider fallback: a "provider/modelId" query that didn't match under the
|
||||
// named provider (exact or fuzzy above) retries against all providers. The
|
||||
// named provider is preferred when present; this only kicks in when it isn't,
|
||||
// so the same model from another provider beats falling back to "inherit".
|
||||
if (slashIdx !== -1) {
|
||||
const bare = resolveModel(input.slice(slashIdx + 1), registry);
|
||||
if (typeof bare !== "string") return bare;
|
||||
}
|
||||
|
||||
// 4. No match — list available models
|
||||
const modelList = all
|
||||
.map(m => ` ${m.provider}/${m.id}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* model-scope.ts — `scopeModels` policy, shared by the top-level Agent tool and
|
||||
* the nested delegation tools so a nested spawn can't escape the allowlist the
|
||||
* top-level path enforces.
|
||||
*
|
||||
* State lives here (rather than in an index.ts closure) for the same reason
|
||||
* `disableDefaults` lives in agent-types.ts: both entry points need it.
|
||||
*/
|
||||
|
||||
import { isModelInScope, type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
||||
|
||||
/**
|
||||
* When enabled, subagent model choices are validated against `enabledModels`
|
||||
* from pi's settings — both global `<agentDir>/settings.json` and project-local
|
||||
* `<cwd>/.pi/settings.json` (project overrides global). Off by default; opt-in
|
||||
* via `/agents → Settings`. See the SubagentsSettings.scopeModels docstring for
|
||||
* the hard-error vs warn-and-proceed policy and its rationale.
|
||||
*/
|
||||
let scopeModelsEnabled = false;
|
||||
|
||||
export function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
||||
export function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
||||
|
||||
export type ModelScopeVerdict =
|
||||
/** In scope, or nothing to validate against (feature off / no allowlist). */
|
||||
| { kind: "ok" }
|
||||
/** Caller-supplied out-of-scope choice — refuse the spawn with this message. */
|
||||
| { kind: "error"; message: string }
|
||||
/** Frontmatter-pinned or parent-inherited — proceed, but tell the user. */
|
||||
| { kind: "warn"; message: string };
|
||||
|
||||
/**
|
||||
* Check the effective resolved model against the user's enabledModels list.
|
||||
*
|
||||
* scopeModels guards against *runtime* LLM choices, not user-level config:
|
||||
* - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
|
||||
* out-of-scope choice; surface it so it picks differently).
|
||||
* - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
|
||||
* user authored/installed this agent or chose the parent's model; trust it).
|
||||
*/
|
||||
export function checkModelScope(args: {
|
||||
model: { provider: string; id: string } | undefined;
|
||||
cwd: string;
|
||||
modelRegistry: ModelRegistryRef;
|
||||
/** True when the model came from the tool call rather than frontmatter. */
|
||||
callerSupplied: boolean;
|
||||
/** Display name used in the warning toast. */
|
||||
agentLabel: string;
|
||||
/** The raw `model:` input, when there was one. */
|
||||
modelInput?: string;
|
||||
}): ModelScopeVerdict {
|
||||
const { model, cwd, modelRegistry, callerSupplied, agentLabel, modelInput } = args;
|
||||
if (!scopeModelsEnabled || !model) return { kind: "ok" };
|
||||
|
||||
const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
|
||||
if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" };
|
||||
|
||||
if (callerSupplied) {
|
||||
const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
|
||||
return {
|
||||
kind: "error",
|
||||
message: `Model not in scope: "${modelInput}".\n\nAllowed models (from enabledModels):\n${list}`,
|
||||
};
|
||||
}
|
||||
const modelLabel = modelInput ?? `${model.provider}/${model.id}`;
|
||||
return {
|
||||
kind: "warn",
|
||||
message: `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type AgentSession,
|
||||
defineTool,
|
||||
type ExtensionAPI,
|
||||
type ExtensionContext,
|
||||
type ToolDefinition,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { abortable } from "./abortable.js";
|
||||
import {
|
||||
buildAgentRegistry,
|
||||
getAgentConfigIn,
|
||||
getAvailableTypesIn,
|
||||
resolveEnabledTypeIn,
|
||||
resolveTypeIn,
|
||||
} from "./agent-types.js";
|
||||
import { loadCustomAgents } from "./custom-agents.js";
|
||||
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
||||
import { resolveModel } from "./model-resolver.js";
|
||||
import { checkModelScope } from "./model-scope.js";
|
||||
import {
|
||||
createOutputFilePath,
|
||||
getOutputTranscriptDefault,
|
||||
streamToOutputFile,
|
||||
writeInitialEntry,
|
||||
} from "./output-file.js";
|
||||
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
||||
import type {
|
||||
AgentConfig,
|
||||
AgentInvocation,
|
||||
AgentRecord,
|
||||
IsolationMode,
|
||||
ThinkingLevel,
|
||||
} from "./types.js";
|
||||
import { addUsage } from "./usage.js";
|
||||
import { isWorktreeIsolationEnabled } from "./worktree.js";
|
||||
|
||||
/**
|
||||
* Hard ceiling on nesting for every branch: main session = 0, its subagents = 1,
|
||||
* their children = 2. `0`/`1` disables nesting entirely. Set from
|
||||
* `subagents.json` (`maxSubagentDepth`). Read when a subagent session is built,
|
||||
* so a change applies to sessions started after it.
|
||||
*/
|
||||
let maxSubagentDepth = 2;
|
||||
|
||||
export function getMaxSubagentDepth(): number { return maxSubagentDepth; }
|
||||
export function setMaxSubagentDepth(n: number): void { maxSubagentDepth = Math.max(0, Math.floor(n)); }
|
||||
|
||||
const NESTED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as const;
|
||||
|
||||
interface NestedSpawnOptions {
|
||||
description: string;
|
||||
model?: Model<any>;
|
||||
maxTurns?: number;
|
||||
isolated?: boolean;
|
||||
inheritContext?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
isBackground?: boolean;
|
||||
isolation?: IsolationMode;
|
||||
invocation?: AgentInvocation;
|
||||
signal?: AbortSignal;
|
||||
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
||||
onSessionCreated?: (session: AgentSession) => void;
|
||||
depth: number;
|
||||
parentAgentId: string;
|
||||
maxSubagentDepth: number;
|
||||
configCwd?: string;
|
||||
rootSessionId?: string;
|
||||
}
|
||||
|
||||
export interface NestedAgentManager {
|
||||
spawn(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
type: string,
|
||||
prompt: string,
|
||||
options: NestedSpawnOptions,
|
||||
): string;
|
||||
spawnAndWait(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
type: string,
|
||||
prompt: string,
|
||||
options: Omit<NestedSpawnOptions, "isBackground">,
|
||||
/** Fires synchronously after spawn, before the session exists — where the transcript is attached. */
|
||||
onSpawned?: (id: string) => void,
|
||||
): Promise<{ id: string; record: AgentRecord }>;
|
||||
getRecord(id: string): AgentRecord | undefined;
|
||||
resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
|
||||
}
|
||||
|
||||
export interface NestedToolContext {
|
||||
manager: NestedAgentManager;
|
||||
pi: ExtensionAPI;
|
||||
parentAgentId: string;
|
||||
depth: number;
|
||||
maxSubagentDepth: number;
|
||||
/** "all" = any enabled agent; string[] = only those types. Never empty. */
|
||||
allowedSubagents: "all" | string[];
|
||||
/** Root used for agent/config discovery; may differ from the agent's working directory. */
|
||||
configCwd: string;
|
||||
}
|
||||
|
||||
function textResult(text: string, isError = false) {
|
||||
return { content: [{ type: "text" as const, text }], isError, details: {} };
|
||||
}
|
||||
|
||||
function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): record is AgentRecord {
|
||||
return record?.parentAgentId === parentAgentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the caller received this record, which decides the outcome wording.
|
||||
*
|
||||
* - "inline": a foreground spawn or a resume. The full output is in this very
|
||||
* result and no agent id was handed back, so the note must say there is
|
||||
* nothing left to fetch — otherwise the parent invents an id, calls
|
||||
* `get_subagent_result`, and hits "not owned by this parent" (#174, the same
|
||||
* trap the top-level foreground path fell into).
|
||||
* - "fetched": `get_subagent_result` on a background child. The parent holds a
|
||||
* valid id and can poll again, so the background wording applies.
|
||||
*/
|
||||
type ResultPosition = "inline" | "fetched";
|
||||
|
||||
function formatRecord(record: AgentRecord, position: ResultPosition): string {
|
||||
if (record.status === "error") {
|
||||
return `Agent failed: ${record.error ?? "unknown error"}${partialOutputSuffix(record)}`;
|
||||
}
|
||||
if (record.status === "queued" || record.status === "running") {
|
||||
return `Agent ${record.id} is ${record.status}.`;
|
||||
}
|
||||
// A truncated run must not read as a finished one. The top-level path carries
|
||||
// this in its result headline; a nested result has no headline, so the note
|
||||
// leads — appended, it would look like part of the child's own output.
|
||||
const text = record.result?.trim() || record.error?.trim() || "No output.";
|
||||
const note = position === "inline"
|
||||
? getForegroundOutcomeNote(record.status)
|
||||
: getStatusNote(record.status);
|
||||
return note ? `Nested agent${note}.\n\n${text}` : text;
|
||||
}
|
||||
|
||||
/** Build child-safe orchestration tools scoped to one parent agent instance. */
|
||||
export function createNestedSubagentTools(context: NestedToolContext): ToolDefinition[] {
|
||||
// Agents resolve from a registry built for THIS branch's config root (under
|
||||
// worktree isolation, the copy). Never via registerAgents — that is
|
||||
// process-global state shared with the main session and every other agent.
|
||||
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd));
|
||||
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
||||
context.allowedSubagents === "all"
|
||||
? undefined
|
||||
: new Set(context.allowedSubagents.map(name => resolveTypeIn(registry, name) ?? name));
|
||||
const availableIn = (registry: Map<string, AgentConfig>): string[] => {
|
||||
const allowed = allowedTypesIn(registry);
|
||||
return getAvailableTypesIn(registry).filter(name => allowed === undefined || allowed.has(name));
|
||||
};
|
||||
|
||||
const agentTool = defineTool({
|
||||
name: NESTED_TOOL_NAMES[0],
|
||||
label: "Agent",
|
||||
description:
|
||||
"Launch a child-safe nested subagent for bounded delegated work. " +
|
||||
"Only use agent types allowed by this parent agent; nesting is depth-limited.",
|
||||
parameters: Type.Object({
|
||||
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
|
||||
description: Type.String({ description: "Short 3-5 word task description." }),
|
||||
subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
|
||||
model: Type.Optional(Type.String({ description: "Optional provider/model override." })),
|
||||
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
||||
max_turns: Type.Optional(Type.Number({ minimum: 1 })),
|
||||
run_in_background: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Defaults to false for nested spawns — the call blocks and returns the child's result inline. Set true only for work you will collect later with get_subagent_result; a detached child is stopped when you finish.",
|
||||
}),
|
||||
),
|
||||
resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
|
||||
isolated: Type.Optional(Type.Boolean()),
|
||||
inherit_context: Type.Optional(Type.Boolean()),
|
||||
...isolationParam(isWorktreeIsolationEnabled()),
|
||||
}),
|
||||
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
||||
if (params.resume) {
|
||||
const existing = context.manager.getRecord(params.resume);
|
||||
if (!ownsRecord(existing, context.parentAgentId)) {
|
||||
return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true);
|
||||
}
|
||||
const resumed = await context.manager.resume(params.resume, params.prompt, signal);
|
||||
return resumed
|
||||
? textResult(formatRecord(resumed, "inline"), resumed.status === "error")
|
||||
: textResult(`Failed to resume nested agent "${params.resume}".`, true);
|
||||
}
|
||||
|
||||
if (context.depth >= context.maxSubagentDepth) {
|
||||
return textResult(
|
||||
`Nested subagent call blocked (depth=${context.depth}, max=${context.maxSubagentDepth}). Complete the task directly.`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Reloaded per call so new agent files are picked up without a restart.
|
||||
const registry = loadRegistry();
|
||||
const rawType = params.subagent_type;
|
||||
// Strict resolve, never the fallback policy: a project-level
|
||||
// `fallbackSubagent` must not hand a nested caller an agent its allowlist
|
||||
// never named. The list stays allowlist-filtered so a typo can't enumerate
|
||||
// agents this parent may not reach.
|
||||
const resolvedType = resolveEnabledTypeIn(registry, rawType);
|
||||
if (resolvedType === undefined) {
|
||||
return textResult(
|
||||
`Unknown or disabled nested agent type: "${rawType}". Allowed: ${availableIn(registry).join(", ") || "none"}.`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
const allowed = allowedTypesIn(registry);
|
||||
if (allowed !== undefined && !allowed.has(resolvedType)) {
|
||||
return textResult(
|
||||
`Nested agent type "${resolvedType}" is not allowed for this parent. Allowed: ${[...allowed].join(", ")}.`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const config = getAgentConfigIn(registry, resolvedType);
|
||||
// Foreground regardless of `backgroundByDefault` — see the reasoning on
|
||||
// ResolveOptions. An explicit `true` here still opts in.
|
||||
const invocation = resolveAgentInvocationConfig(config, params, {
|
||||
worktreeAllowed: isWorktreeIsolationEnabled(),
|
||||
defaultRunInBackground: false,
|
||||
});
|
||||
let model = ctx.model;
|
||||
if (invocation.modelInput) {
|
||||
const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
|
||||
if (typeof resolvedModel === "string") {
|
||||
if (invocation.modelFromParams) return textResult(resolvedModel, true);
|
||||
} else {
|
||||
model = resolvedModel;
|
||||
}
|
||||
}
|
||||
|
||||
// Same scopeModels policy as the top-level Agent tool — a nested spawn
|
||||
// must not escape the allowlist. A "warn" verdict proceeds silently:
|
||||
// child sessions have no UI surface to toast to.
|
||||
const scopeVerdict = checkModelScope({
|
||||
model,
|
||||
cwd: context.configCwd,
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
callerSupplied: invocation.modelFromParams,
|
||||
agentLabel: config?.displayName ?? resolvedType,
|
||||
modelInput: invocation.modelInput,
|
||||
});
|
||||
if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true);
|
||||
|
||||
// The whole branch shares the root session's transcript directory; read it
|
||||
// off the owning parent rather than this child session's own id.
|
||||
const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId;
|
||||
const childDepth = context.depth + 1;
|
||||
const options: NestedSpawnOptions = {
|
||||
description: params.description,
|
||||
model,
|
||||
maxTurns: invocation.maxTurns,
|
||||
isolated: invocation.isolated,
|
||||
inheritContext: invocation.inheritContext,
|
||||
thinkingLevel: invocation.thinking,
|
||||
isolation: invocation.isolation,
|
||||
invocation: {
|
||||
thinking: invocation.thinking,
|
||||
maxTurns: invocation.maxTurns,
|
||||
isolated: invocation.isolated,
|
||||
inheritContext: invocation.inheritContext,
|
||||
runInBackground: invocation.runInBackground,
|
||||
isolation: invocation.isolation,
|
||||
},
|
||||
// Nested children are hidden from every reporting surface, so their spend
|
||||
// would otherwise be unattributable. Fold it into every ancestor's record:
|
||||
// the top-level one appears in lifecycle events, completion notifications,
|
||||
// and `/agents`, and those all read `lifetimeUsage`. The whole chain is
|
||||
// walked, not just the immediate parent — a spawn callback only fires for
|
||||
// that child's OWN turns, so stopping at one level would hide a
|
||||
// great-grandchild from the only record anyone can see. (The live
|
||||
// widget/fleet counters read their own per-agent activity tracker, which
|
||||
// still sees only the top-level agent's own turns.)
|
||||
onAssistantUsage: (usage) => {
|
||||
for (let id: string | undefined = context.parentAgentId; id !== undefined; ) {
|
||||
const ancestor = context.manager.getRecord(id);
|
||||
if (!ancestor) break;
|
||||
addUsage(ancestor.lifetimeUsage, usage);
|
||||
id = ancestor.parentAgentId;
|
||||
}
|
||||
},
|
||||
depth: childDepth,
|
||||
parentAgentId: context.parentAgentId,
|
||||
maxSubagentDepth: context.maxSubagentDepth,
|
||||
configCwd: context.configCwd,
|
||||
rootSessionId,
|
||||
};
|
||||
|
||||
// Transcript wiring, same gate as the top-level path: the child's
|
||||
// `output_transcript` frontmatter wins, else the project default. Without
|
||||
// it a nested run leaves no artifact but the string it returned — the
|
||||
// parent's own transcript records the call and the answer, never the tool
|
||||
// calls in between, which is exactly what a misbehaving child needs to
|
||||
// explain itself. Filed under the ROOT session and this branch's config
|
||||
// root, so a nested transcript lands in the same `tasks/` directory as its
|
||||
// ancestors' rather than in a directory of its own.
|
||||
const transcriptSessionId =
|
||||
rootSessionId !== undefined && (config?.outputTranscript ?? getOutputTranscriptDefault())
|
||||
? rootSessionId
|
||||
: undefined;
|
||||
let childId: string | undefined;
|
||||
const attachTranscript = (id: string): void => {
|
||||
childId = id;
|
||||
if (transcriptSessionId === undefined) return;
|
||||
const rec = context.manager.getRecord(id);
|
||||
if (!rec) return;
|
||||
rec.outputFile = createOutputFilePath(context.configCwd, id, transcriptSessionId);
|
||||
writeInitialEntry(rec.outputFile, id, params.prompt, ctx.cwd);
|
||||
};
|
||||
options.onSessionCreated = (session) => {
|
||||
const rec = childId === undefined ? undefined : context.manager.getRecord(childId);
|
||||
if (rec?.outputFile && childId !== undefined) {
|
||||
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, childId, ctx.cwd);
|
||||
}
|
||||
};
|
||||
|
||||
// `ctx` is forwarded to the manager unmodified, never captured at tool-build
|
||||
// time: each AgentSession builds its own ExtensionRunner from that session's
|
||||
// cwd/sessionManager/modelRegistry, so this is the CHILD's context. Capturing
|
||||
// one earlier would silently give a grandchild the wrong worktree base, the
|
||||
// wrong conversation under inherit_context, and the wrong inherited model.
|
||||
//
|
||||
// spawn() throws on strict worktree-isolation failure and cwd validation —
|
||||
// report it as a tool error, like the top-level Agent tool does, instead of
|
||||
// letting it escape into the child's turn.
|
||||
try {
|
||||
if (invocation.runInBackground) {
|
||||
const id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, {
|
||||
...options,
|
||||
isBackground: true,
|
||||
});
|
||||
// Synchronous, before the event loop yields — onSessionCreated fires
|
||||
// asynchronously inside runAgent, so the file is attached in time.
|
||||
attachTranscript(id);
|
||||
return textResult(`Nested agent started in background. Agent ID: ${id}`);
|
||||
}
|
||||
|
||||
const { record } = await context.manager.spawnAndWait(
|
||||
context.pi,
|
||||
ctx,
|
||||
resolvedType,
|
||||
params.prompt,
|
||||
{ ...options, signal },
|
||||
attachTranscript,
|
||||
);
|
||||
return textResult(formatRecord(record, "inline"), record.status === "error");
|
||||
} catch (err) {
|
||||
return textResult(err instanceof Error ? err.message : String(err), true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const resultTool = defineTool({
|
||||
name: NESTED_TOOL_NAMES[1],
|
||||
label: "Get Nested Agent Result",
|
||||
description: "Check or wait for a background nested agent owned by this parent.",
|
||||
parameters: Type.Object({
|
||||
agent_id: Type.String(),
|
||||
wait: Type.Optional(Type.Boolean()),
|
||||
}),
|
||||
execute: async (_toolCallId, params, signal) => {
|
||||
const record = context.manager.getRecord(params.agent_id);
|
||||
if (!ownsRecord(record, context.parentAgentId)) {
|
||||
return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
|
||||
}
|
||||
// Wait for completion if requested. Cancellation (e.g. the parent's tool
|
||||
// call is aborted) stops only this wait; the nested child keeps running and
|
||||
// stays unconsumed. Queued records have no promise until the manager starts
|
||||
// them, so poll — abortably — until they leave the queue, then await.
|
||||
if (params.wait && (record.status === "queued" || record.status === "running")) {
|
||||
while (record.status === "queued") {
|
||||
await abortable(new Promise<void>(resolve => setTimeout(resolve, 250)), signal);
|
||||
}
|
||||
if (record.promise) await abortable(record.promise, signal);
|
||||
}
|
||||
return textResult(formatRecord(record, "fetched"), record.status === "error");
|
||||
},
|
||||
});
|
||||
|
||||
const steerTool = defineTool({
|
||||
name: NESTED_TOOL_NAMES[2],
|
||||
label: "Steer Nested Agent",
|
||||
description: "Send guidance to a running nested agent owned by this parent.",
|
||||
parameters: Type.Object({
|
||||
agent_id: Type.String(),
|
||||
message: Type.String(),
|
||||
}),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const record = context.manager.getRecord(params.agent_id);
|
||||
if (!ownsRecord(record, context.parentAgentId) || record.status !== "running") {
|
||||
return textResult(`Running nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
|
||||
}
|
||||
// Session not ready yet — queue the steer. The manager flushes pending
|
||||
// steers when the session is created (same contract as the top-level tool).
|
||||
if (!record.session) {
|
||||
if (!record.pendingSteers) record.pendingSteers = [];
|
||||
record.pendingSteers.push(params.message);
|
||||
return textResult(`Steering message queued for nested agent ${params.agent_id}.`);
|
||||
}
|
||||
try {
|
||||
await record.session.steer(params.message);
|
||||
} catch (err) {
|
||||
return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true);
|
||||
}
|
||||
return textResult(`Steering message sent to nested agent ${params.agent_id}.`);
|
||||
},
|
||||
});
|
||||
|
||||
return [agentTool, resultTool, steerTool];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user