From a07d2ae76dc9744500dbf6dcd19735df9c3a521b Mon Sep 17 00:00:00 2001 From: TreeWork Development Date: Fri, 7 Aug 2026 15:34:19 +0800 Subject: [PATCH 1/2] feat: add focused Pi host adapter Reuse TreeWork's Skill, CLI, transactions, and MCP server from Pi while preserving one project-state model. Add explicit session handoff commands, lifecycle guardrails, lazy read-only tools, reversible packaging, and end-to-end RPC tests.\n\nCloses #6 --- .github/workflows/ci.yml | 3 + AGENTS.md | 11 +- CONTRIBUTING.md | 7 +- Makefile | 10 +- README.md | 58 +- README.zh-CN.md | 56 +- RELEASE-NOTES.md | 17 + adapters/pi/README.md | 69 +++ adapters/pi/core.mjs | 119 ++++ adapters/pi/index.ts | 575 ++++++++++++++++++ adapters/pi/mcp-client.mjs | 202 ++++++ adapters/pi/tests/core.test.mjs | 107 ++++ adapters/pi/tests/mcp-client.test.mjs | 84 +++ docs/README.md | 6 + docs/development.md | 28 +- docs/releasing.md | 10 + package.json | 21 + .../treework/crates/treework-cli/src/main.rs | 2 +- plugins/treework/skills/treework/SKILL.md | 6 + .../treework/references/02-build-tree.md | 12 +- scripts/check_pi_adapter.py | 241 ++++++++ scripts/check_pi_workspace_switch.py | 383 ++++++++++++ 22 files changed, 1974 insertions(+), 53 deletions(-) create mode 100644 adapters/pi/README.md create mode 100644 adapters/pi/core.mjs create mode 100644 adapters/pi/index.ts create mode 100644 adapters/pi/mcp-client.mjs create mode 100644 adapters/pi/tests/core.test.mjs create mode 100644 adapters/pi/tests/mcp-client.test.mjs create mode 100644 package.json create mode 100755 scripts/check_pi_adapter.py create mode 100755 scripts/check_pi_workspace_switch.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e23f83b..39eda6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: - name: Install development dependencies run: | python -m pip install -r requirements-dev.txt + npm install --global @earendil-works/pi-coding-agent@0.84.0 cd project-map-ui npm ci @@ -58,4 +59,6 @@ jobs: python scripts/check_mcp.py python scripts/test_check_activation.py python scripts/test_macos_quarantine_bootstrap.py + TREEWORK_REQUIRE_PI=1 python scripts/check_pi_adapter.py + TREEWORK_REQUIRE_PI=1 python scripts/check_pi_workspace_switch.py python scripts/check_packaging.py diff --git a/AGENTS.md b/AGENTS.md index 6fc3bc1..640029f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # TreeWork Repository Guidance -This repository contains both an installable Codex plugin and the source used -to maintain it. +This repository contains an installable Codex plugin, a focused Pi package +adapter, and the shared source used to maintain both. ## Read Before Editing @@ -16,8 +16,11 @@ to maintain it. ## Repository Boundaries -- `plugins/treework/` is the installable plugin. Keep it free of - project history, UI source, prototypes, and maintainer-only documents. +- `plugins/treework/` is the installable Codex plugin and shared runtime. Keep + it free of project history, UI source, prototypes, and maintainer-only + documents. +- `adapters/pi/` is the focused Pi host surface. Reuse the shared Skill, CLI, + transactions, and MCP server; do not fork their state or semantics. - Agent references explain how to use TreeWork. Do not put Rust modules, API internals, migration plans, or frontend architecture there. - `project-map-ui/` is source; `plugins/treework/assets/graph-panel/` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a8966a..3d242af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ TreeWork accepts focused fixes and improvements that strengthen the shared project-state protocol without adding project-specific policy to the core. -TreeWork currently ships and is release-tested as a Codex plugin. Contributions +TreeWork currently ships and is release-tested for Codex and Pi. Contributions for other coding-agent hosts are welcome when they preserve the same document, transaction, lifecycle, and verification semantics through a focused adapter. @@ -11,8 +11,9 @@ transaction, lifecycle, and verification semantics through a focused adapter. - Project Map interaction design, navigation, accessibility, responsive behavior, and large-Tree performance. -- Host adapters for Claude Code, Cursor, Gemini CLI, OpenCode, and other coding - agents, with installation documentation and host-specific tests. +- Improvements to the Pi adapter and focused host adapters for Claude Code, + Cursor, Gemini CLI, OpenCode, and other coding agents, with installation + documentation and host-specific tests. - Controlled evaluations of state recovery, agent handoffs, development drift, quality, and operational overhead. - Documentation, examples, translations, packaging, and platform support. diff --git a/Makefile b/Makefile index 8b73fdc..2520bc6 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,9 @@ PLUGIN_CREATOR ?= $(HOME)/.codex/skills/.system/plugin-creator SKILL_CREATOR ?= $(HOME)/.codex/skills/.system/skill-creator ARTIFACTS ?= .artifacts -.PHONY: test test-rust test-ui test-runtime validate build-ui package browser-test +.PHONY: test test-rust test-ui test-runtime test-pi validate build-ui package browser-test -test: test-rust test-ui test-runtime +test: test-rust test-ui test-runtime test-pi test-rust: cargo test --manifest-path $(CLI_MANIFEST) @@ -23,12 +23,18 @@ test-runtime: python3 scripts/test_check_activation.py python3 scripts/test_macos_quarantine_bootstrap.py +# Pi is optional for local contributors, but CI sets TREEWORK_REQUIRE_PI=1. +test-pi: + python3 scripts/check_pi_adapter.py + python3 scripts/check_pi_workspace_switch.py + build-ui: cd project-map-ui && npm run build validate: build-ui git diff --exit-code -- $(PLUGIN)/assets/graph-panel python3 scripts/check_packaging.py + python3 scripts/check_pi_adapter.py python3 $(PLUGIN_CREATOR)/scripts/validate_plugin.py $(PLUGIN) python3 $(SKILL_CREATOR)/scripts/quick_validate.py \ $(PLUGIN)/skills/treework diff --git a/README.md b/README.md index ced09aa..88c0c74 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ # TreeWork -TreeWork is a **tree-guided development plugin for Codex**. It helps coding -agents organize complex projects as branches, prepare the important design -before coding, and move through long-running work without losing direction. +TreeWork is a **tree-guided development system for coding agents**. It ships as +a Codex plugin and a focused Pi host adapter. TreeWork helps agents organize +complex projects as branches, prepare the important design before coding, and +move through long-running work without losing direction. Code inspection shows what exists and retrieval memory recalls fragments, but neither reliably tells an Agent what the project has accepted, where work @@ -23,12 +24,30 @@ scratch. ## First Install -TreeWork currently targets Codex on macOS and Linux. Native Windows support has -not been release-tested. +TreeWork currently targets Codex and Pi on macOS and Linux. Native Windows +support has not been release-tested. Runtime prerequisites: Git, Bash, Python 3, Rust, and Cargo. The Project Map frontend is bundled; Node.js is needed only for frontend development. +### Pi + +Install the focused Pi package directly from this repository: + +```bash +pi install git:github.com/Johnny-xuan/TreeWork +``` + +Restart Pi, run `/treework-adapter` to verify the runtime, then invoke +`/skill:treework` or ask Pi to use TreeWork. The adapter reuses the shipped +Skill and MCP server, loads read-only tools on demand, ports TreeWork's mutation +and stop-check guardrails, and provides explicit `/treework-enter` and +`/treework-return` commands that fork the conversation across cwd-bound Pi +sessions. See [TreeWork for Pi](adapters/pi/README.md) for +the complete install, use, verification, and rollback contract. + +### Codex guided install + Give the following prompt to a Codex Agent with terminal access: ```text @@ -67,7 +86,7 @@ inside the current project until I explicitly approve it. new one. Leave project initialization to the new Codex task after I choose. ``` -### Manual Install +### Codex manual install ```bash codex plugin marketplace add https://github.com/Johnny-xuan/TreeWork @@ -171,8 +190,10 @@ TreeWork includes a local, read-only Project Map: - **Dependency** shows prerequisites and downstream work for one branch. - **Replay** reconstructs accepted TreeWork transitions over time. -After the first Tree is accepted, the Agent opens Project Map in the Codex -in-app browser. The panel projects accepted state; it does not edit the project. +After the first Tree is accepted, the Agent uses its host adapter's Project Map +handoff. Codex opens the localhost URL in its in-app browser; Pi returns the URL +and opens the system browser only on explicit request. The panel projects +accepted state; it does not edit the project. ## Design Rationale @@ -204,7 +225,7 @@ Read the formal model and evaluation design in the ## Package Contents -The installable plugin lives at +The installable Codex plugin lives at [`plugins/treework`](plugins/treework) and includes: - the staged project-state Skill and Agent-facing references; @@ -213,12 +234,16 @@ The installable plugin lives at - a local read-only MCP server for Recall and Project Map launch; - bundled Project Map assets. -TreeWork stores project state under `.TreeWork/`. +The Pi package manifest and focused extension live under +[`adapters/pi`](adapters/pi) and directly reuse that same Skill, runtime, and +MCP server. TreeWork stores project state under `.TreeWork/`; neither host +adapter creates a second source of truth. ## Repository Layout ```text -plugins/treework/ Installable Codex plugin +plugins/treework/ Installable Codex plugin and shared runtime +adapters/pi/ Focused Pi extension, tests, and host docs project-map-ui/ React/D3/SVG Project Map source docs/product/ Product behavior and UX contracts docs/architecture/ Runtime and transaction contracts @@ -231,7 +256,7 @@ while using TreeWork. Maintainer implementation contracts stay under `docs/`. ## Community and Help Wanted -TreeWork currently ships and is release-tested as a Codex plugin. Support for +TreeWork currently ships and is release-tested for Codex and Pi. Support for Claude Code, Cursor, Gemini CLI, OpenCode, and other agent hosts is welcome through focused host adapters. @@ -268,10 +293,11 @@ make validate ## Status -`v0.1.7` is the current version. Alignment, declarative Tree construction, -hierarchy-aligned branch documents, protected branch traversal, Recall, Project -Map, and Replay form a usable end-to-end loop. Project Map interaction design -will continue to evolve. +`v0.1.7` is the current runtime version. Alignment, declarative Tree +construction, hierarchy-aligned branch documents, protected branch traversal, +Recall, Project Map, and Replay form a usable end-to-end loop. Codex and Pi host +surfaces share those semantics. Project Map interaction design will continue to +evolve. ## Privacy diff --git a/README.zh-CN.md b/README.zh-CN.md index 0317bd0..b00c6f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,9 +10,9 @@ # TreeWork -TreeWork 是一个面向 Codex 的**树引导开发插件**。它帮助 Coding Agent 把复杂 -项目组织成一棵 branch 树,在编码前完成重要设计,并在长期开发中沿着树移动而 -不失去方向。 +TreeWork 是一套面向 Coding Agent 的**树引导开发系统**,同时提供 Codex 插件 +和聚焦的 Pi host adapter。它帮助 Agent 把复杂项目组织成一棵 branch 树,在 +编码前完成重要设计,并在长期开发中沿着树移动而不失去方向。 代码检查能够看到已经实现了什么,检索型记忆能够找回历史片段,但两者都不能 稳定回答项目已经接受了什么、工作进行到哪里,以及上一个 Agent 为什么停下。 @@ -21,12 +21,29 @@ TreeWork 将已接受的项目结构、branch 状态、Spec、进度、结论和 ## 首次安装 -TreeWork 当前面向 macOS 和 Linux 上的 Codex。原生 Windows 支持尚未经过发布 -测试。 +TreeWork 当前面向 macOS 和 Linux 上的 Codex 与 Pi。原生 Windows 支持尚未 +经过发布测试。 运行依赖包括 Git、Bash、Python 3、Rust 和 Cargo。Project Map 前端已经打包; 只有开发前端时才需要 Node.js。 +### Pi + +直接从本仓库安装 Pi package: + +```bash +pi install git:github.com/Johnny-xuan/TreeWork +``` + +重启 Pi,运行 `/treework-adapter` 验证运行时,然后执行 `/skill:treework` 或直接 +要求 Pi 使用 TreeWork。适配器直接复用仓库中的 Skill 和 MCP 服务,按需加载只读 +工具,移植 TreeWork 的写保护与 stop check,并通过显式的 `/treework-enter` 与 +`/treework-return` 命令 fork cwd 绑定的 Pi 会话,把完整对话移入或移出 branch +worktree。完整安装、使用、验证与回滚方式见 +[TreeWork for Pi](adapters/pi/README.md)。 + +### Codex 引导安装 + 把下面这段 prompt 直接交给一个能够使用终端的 Codex Agent: ```text @@ -59,7 +76,7 @@ TreeWork。 后,再进行项目初始化。 ``` -### 手动安装 +### Codex 手动安装 ```bash codex plugin marketplace add https://github.com/Johnny-xuan/TreeWork @@ -157,8 +174,9 @@ TreeWork 包含一个本地只读 Project Map: - **Dependency** 展示某个 branch 的前置依赖和下游工作; - **Replay** 按时间重建已接受的 TreeWork 状态转移。 -第一个 Tree 被接受后,Agent 会在 Codex 内置浏览器中打开 Project Map。面板只 -投影已接受状态,不直接编辑项目。 +第一个 Tree 被接受后,Agent 使用当前 host adapter 提供的 Project Map 交接。 +Codex 会在内置浏览器中打开本地 URL;Pi 返回该 URL,只有在明确请求时才打开系统 +浏览器。面板只投影已接受状态,不直接编辑项目。 ## 设计理由 @@ -188,8 +206,7 @@ Agent 在其中移动的有效方式,同时把局部实现决策留给 Agent ## 插件内容 -可安装插件位于 [`plugins/treework`](plugins/treework), -其中包括: +可安装的 Codex 插件位于 [`plugins/treework`](plugins/treework),其中包括: - 分阶段项目状态 Skill 及面向 Agent 的参考文档; - Rust 编写的 `tw` 事务运行时; @@ -197,12 +214,15 @@ Agent 在其中移动的有效方式,同时把局部实现决策留给 Agent - 用于 Recall 和启动 Project Map 的本地只读 MCP 服务; - 已打包的 Project Map 资源。 -TreeWork 将项目状态保存在 `.TreeWork/` 下。 +Pi package manifest 和聚焦的扩展位于 [`adapters/pi`](adapters/pi),并直接复用 +同一份 Skill、运行时和 MCP 服务。TreeWork 将项目状态保存在 `.TreeWork/` 下; +两个 host adapter 都不会创建第二事实源。 ## 仓库结构 ```text -plugins/treework/ 可安装的 Codex 插件 +plugins/treework/ 可安装的 Codex 插件及共享运行时 +adapters/pi/ 聚焦的 Pi 扩展、测试与 host 文档 project-map-ui/ React/D3/SVG Project Map 源码 docs/product/ 产品行为和交互契约 docs/architecture/ 运行时和 transaction 契约 @@ -215,9 +235,8 @@ paper/ 研究论文源码与图片 ## 社区参与 -TreeWork 目前以 Codex 插件形式提供,并以 Codex 作为发布测试目标。欢迎贡献者 -通过聚焦的 host adapter,为 Claude Code、Cursor、Gemini CLI、OpenCode 等 -Agent host 增加支持。 +TreeWork 目前为 Codex 和 Pi 提供发布测试支持。欢迎贡献者通过聚焦的 host +adapter,为 Claude Code、Cursor、Gemini CLI、OpenCode 等 Agent host 增加支持。 当前特别需要贡献者参与的方向包括: @@ -246,9 +265,10 @@ make validate ## 当前状态 -`v0.1.7` 是当前版本。Alignment、声明式 Tree 构建、与 Tree 层级一致的 branch -文档、受保护的 branch 移动、Recall、Project Map 和 Replay 已经形成可用的 -端到端闭环。Project Map 的交互设计仍会持续演化。 +`v0.1.7` 是当前运行时版本。Alignment、声明式 Tree 构建、与 Tree 层级一致的 +branch 文档、受保护的 branch 移动、Recall、Project Map 和 Replay 已经形成 +可用的端到端闭环,Codex 与 Pi host surface 共享这些语义。Project Map 的交互 +设计仍会持续演化。 ## 隐私 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 8a3c75a..37d4566 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,5 +1,22 @@ # Release Notes +## Unreleased - Pi Host Adapter + +- Adds a focused Pi package that directly reuses TreeWork's Skill, Rust runtime, + and read-only MCP server without introducing a second project-state model. +- Keeps Pi's always-active Agent-tool surface to one deferred loader for Recall, + Check, and Project Map; explicit host commands own cwd-bound Enter and Return. +- Ports machine-owned-state protection and the stop-check boundary to Pi + extension lifecycle events. +- Makes `/treework-enter` wait for Pi to become idle, prepares the conversation + fork before the state transition, and recovers cancelled switches by pausing + the branch and removing the unused fork. +- Forks the current Pi conversation into managed branch worktrees and back to + the control workspace so cwd-bound tools and project context reload without + losing history. +- Adds guardrail, MCP, package-load, and real offline Pi RPC round-trip tests, + plus reversible installation and rollback documentation. + ## v0.1.7 - Hierarchical Branch Artifacts - Projects branch documents onto the same parent-child hierarchy as the diff --git a/adapters/pi/README.md b/adapters/pi/README.md new file mode 100644 index 0000000..0d37886 --- /dev/null +++ b/adapters/pi/README.md @@ -0,0 +1,69 @@ +# TreeWork for Pi + +This adapter preserves TreeWork's existing protocol while mapping host-specific surfaces to Pi: + +- the existing Agent Skill is loaded directly from `plugins/treework/skills/treework`; +- TreeWork's existing read-only MCP server backs Recall, Check, and Project Map tools; +- a small deferred loader keeps Pi's initial tool context minimal; +- explicit `/treework-enter` and `/treework-return` commands own cwd-bound + session replacement, which Pi does not expose safely from an Agent tool; +- Pi `tool_call` hooks guard direct access to machine-owned TreeWork state; +- Pi `agent_settled` runs the existing `tw check --brief` boundary; +- Enter and Return fork the current Pi conversation into the target workspace so Pi rebuilds cwd-bound tools, project context, trust, settings, and resources correctly. + +## Install + +```bash +pi install git:github.com/Johnny-xuan/TreeWork +``` + +Restart Pi after installation. The package does not read or copy credentials. + +To try a checkout without installing it: + +```bash +pi -e /path/to/TreeWork/adapters/pi/index.ts \ + --skill /path/to/TreeWork/plugins/treework/skills/treework +``` + +## Use + +Ask Pi to use TreeWork, or invoke `/skill:treework`. The adapter initially exposes only `treework_tools`; load `memory`, `map`, or `all` as needed. + +When the Agent selects a branch, invoke: + +```text +/treework-enter +``` + +This explicit host command waits until Pi is idle, requires a persisted session, prepares the target conversation fork before changing TreeWork state, performs the accepted Enter transaction, and switches into the managed Git worktree with full history. Explicit command ownership is necessary because Pi's Agent-tool context cannot safely replace its own cwd-bound session. If another extension cancels the switch, the adapter immediately pauses the entered branch and removes the unused fork. + +After synchronizing and committing branch work, invoke `/treework-return` to move the conversation back to the same project's control workspace. Lifecycle transitions remain owned by `tw`; the adapter does not invent new state. + +Project Map returns a loopback URL. It opens the system browser only when the caller explicitly sets `open: true`. + +The tool hook blocks direct file-tool paths plus common explicit, split, globbed, and symlinked Bash paths into generated state. Like TreeWork's Codex hook, it is a cooperative Agent guardrail rather than a hostile-shell sandbox; do not try to obfuscate paths or bypass `tw` transactions. + +## Roll back + +```bash +pi remove git:github.com/Johnny-xuan/TreeWork +``` + +TreeWork project state and Git worktrees are not deleted by uninstalling the adapter. The Rust build cache is also retained so reinstalling does not rebuild it. To remove that optional cache after uninstalling: + +```bash +rm -rf ~/.pi/agent/cache/treework +``` + +Set `PI_CODING_AGENT_DIR` accordingly if Pi uses a non-default agent directory. + +## Verify + +```bash +node --test adapters/pi/tests/*.test.mjs +python3 scripts/check_pi_adapter.py +python3 scripts/check_pi_workspace_switch.py +make test +make validate +``` diff --git a/adapters/pi/core.mjs b/adapters/pi/core.mjs new file mode 100644 index 0000000..e81bc25 --- /dev/null +++ b/adapters/pi/core.mjs @@ -0,0 +1,119 @@ +import { existsSync, globSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, normalize, resolve, sep } from "node:path"; + +const GENERATED_MARKERS = [ + "treework:status:start", + "treework:root-status:start", + "treework:branch-table:start", +]; + +export const TREEWORK_CAPABILITY_TOOLS = Object.freeze({ + memory: Object.freeze(["treework_recall", "treework_check"]), + map: Object.freeze(["treework_project_map"]), +}); + +export const TREEWORK_TOOLS = Object.freeze([ + ...TREEWORK_CAPABILITY_TOOLS.memory, + ...TREEWORK_CAPABILITY_TOOLS.map, +]); + +function normalizedPath(path) { + return normalize(path).split(sep).join("/"); +} + +function canonicalizeLoose(path) { + let existing = path; + const missing = []; + while (!existsSync(existing)) { + const parent = dirname(existing); + if (parent === existing) break; + missing.unshift(basename(existing)); + existing = parent; + } + try { + return resolve(realpathSync(existing), ...missing); + } catch { + return path; + } +} + +function hasProtectedPathShape(path) { + const normalized = normalizedPath(path); + return ( + normalized.includes("/.TreeWork/state/") || + normalized.endsWith("/.TreeWork/state") || + normalized.endsWith("/.TreeWork/events.jsonl") + ); +} + +export function isProtectedTreeWorkPath(path, cwd = process.cwd()) { + if (typeof path !== "string" || path.trim() === "") return false; + const absolute = isAbsolute(path) ? path : resolve(cwd, path); + return hasProtectedPathShape(absolute) || hasProtectedPathShape(canonicalizeLoose(absolute)); +} + +export function containsGeneratedTreeWorkMarker(value) { + if (typeof value !== "string") return false; + return GENERATED_MARKERS.some((marker) => value.includes(marker)); +} + +export function shouldBlockProtectedTreeWorkAccess(toolName, input, cwd = process.cwd()) { + if (toolName === "read") { + const path = typeof input?.path === "string" ? input.path : ""; + return isProtectedTreeWorkPath(path, cwd); + } + + if (toolName === "write" || toolName === "edit") { + const path = typeof input?.path === "string" ? input.path : ""; + if (isProtectedTreeWorkPath(path, cwd)) return true; + return containsGeneratedTreeWorkMarker(JSON.stringify(input ?? {})); + } + + if (toolName !== "bash") return false; + const command = typeof input?.command === "string" ? input.command : ""; + if (!command) return false; + const unquoted = command.replace(/["']/g, ""); + const explicitProtectedPath = + unquoted.includes(".TreeWork/state/") || + unquoted.includes(".TreeWork/events.jsonl"); + const splitProtectedPath = + unquoted.includes(".TreeWork") && + (/(?:^|[\s/])st(?:ate|[?*\[].*?)(?:[\s/]|$)/m.test(unquoted) || + /(?:^|[\s/])event(?:s|[?*\[].*?)\.jsonl(?:[\s/]|$)/m.test(unquoted)); + const pathTokens = command + .split(/[\s;&|<>]+/) + .map((token) => token.replace(/^["'`()]+|["'`()]+$/g, "")) + .filter((token) => token.includes("/")); + const expandedProtectedPath = pathTokens.some((token) => { + if (isProtectedTreeWorkPath(token, cwd)) return true; + try { + return globSync(token, { cwd }).some((match) => isProtectedTreeWorkPath(match, cwd)); + } catch { + return false; + } + }); + return ( + explicitProtectedPath || + splitProtectedPath || + expandedProtectedPath || + containsGeneratedTreeWorkMarker(command) + ); +} + +export function resetTreeWorkTools(activeTools) { + return [...new Set([...activeTools.filter((name) => !TREEWORK_TOOLS.includes(name)), "treework_tools"])]; +} + +export function activateTreeWorkTools(activeTools, capability) { + const requested = + capability === "all" ? TREEWORK_TOOLS : TREEWORK_CAPABILITY_TOOLS[capability] ?? []; + return [...new Set([...activeTools, ...requested])]; +} + +export function extractTreeWorkWorkspace(output) { + if (typeof output !== "string") return undefined; + const match = output.match(/^\s*workspace:\s*(.+?)\s*$/m); + if (!match) return undefined; + const workspace = match[1]?.trim(); + return workspace && isAbsolute(workspace) ? normalize(workspace) : undefined; +} diff --git a/adapters/pi/index.ts b/adapters/pi/index.ts new file mode 100644 index 0000000..e85dc38 --- /dev/null +++ b/adapters/pi/index.ts @@ -0,0 +1,575 @@ +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + SessionManager, + truncateHead, + type ExtensionAPI, + type ExtensionCommandContext, +} from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { + activateTreeWorkTools, + extractTreeWorkWorkspace, + resetTreeWorkTools, + shouldBlockProtectedTreeWorkAccess, + TREEWORK_TOOLS, +} from "./core.mjs"; +import { TreeWorkMcpClient } from "./mcp-client.mjs"; + +const adapterRoot = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(adapterRoot, "../.."); +const pluginRoot = join(repositoryRoot, "plugins", "treework"); +const skillRoot = join(pluginRoot, "skills", "treework"); +const twPath = join(skillRoot, "scripts", "tw"); +const adapterVersion = JSON.parse(readFileSync(join(repositoryRoot, "package.json"), "utf8")).version; +const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); +const buildDir = join(agentDir, "cache", "treework"); + +interface ControlDescriptor { + version: number; + project_id: string; + control_root: string; +} + +interface StrictControlDescriptor extends ControlDescriptor { + controlRoot: string; + commonDir: string; +} + +interface WorktreeBinding { + version: number; + project_id: string; + branch: string; + workspace: string; +} + +interface EnterCommand { + branch: string; + recall: boolean; + resume: boolean; +} + +interface PreparedFork { + targetFile: string; + target: string; +} + +function commandOutput(result: { stdout: string; stderr: string }): string { + return [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n"); +} + +function findTreeWorkAncestor(cwd: string): string | undefined { + let current: string; + try { + current = realpathSync(cwd); + } catch { + return undefined; + } + while (true) { + if (existsSync(join(current, ".TreeWork"))) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +function parseEnterCommand(args: string): EnterCommand { + const tokens = args.trim().split(/\s+/).filter(Boolean); + const branch = tokens.shift(); + if (!branch || branch.length > 512) { + throw new Error("Usage: /treework-enter [--recall]"); + } + const unknown = tokens.filter((token) => token !== "--recall" && token !== "--no-resume"); + if (unknown.length) throw new Error(`Unknown TreeWork Enter option(s): ${unknown.join(", ")}`); + return { + branch, + recall: tokens.includes("--recall"), + resume: !tokens.includes("--no-resume"), + }; +} + +function parseReturnCommand(args: string): { resume: boolean } { + const tokens = args.trim().split(/\s+/).filter(Boolean); + const unknown = tokens.filter((token) => token !== "--no-resume"); + if (unknown.length) throw new Error(`Unknown TreeWork Return option(s): ${unknown.join(", ")}`); + return { resume: !tokens.includes("--no-resume") }; +} + +function boundedText(content: Array<{ type: "text"; text: string }>) { + const text = content.map((part) => part.text).join("\n"); + const truncation = truncateHead(text, { + maxBytes: DEFAULT_MAX_BYTES - 1024, + maxLines: DEFAULT_MAX_LINES - 3, + }); + const suffix = truncation.truncated + ? `\n\n[TreeWork output truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines and ${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}.]` + : ""; + return { + content: [{ type: "text" as const, text: `${truncation.content}${suffix}` }], + truncation: { + truncated: truncation.truncated, + truncatedBy: truncation.truncatedBy, + totalLines: truncation.totalLines, + totalBytes: truncation.totalBytes, + }, + }; +} + +async function runTw( + pi: ExtensionAPI, + cwd: string, + args: string[], + signal?: AbortSignal, + timeout = 120_000, +) { + mkdirSync(buildDir, { recursive: true }); + return pi.exec( + "env", + [ + `TREEWORK_PLUGIN_ROOT=${pluginRoot}`, + `TREEWORK_BUILD_DIR=${buildDir}`, + twPath, + ...args, + ], + { cwd, signal, timeout }, + ); +} + +async function gitPath(pi: ExtensionAPI, cwd: string, flag: "--git-dir" | "--git-common-dir") { + const result = await pi.exec("git", ["rev-parse", flag], { cwd, timeout: 10_000 }); + if (result.code !== 0) { + throw new Error(commandOutput(result) || `git rev-parse ${flag} failed`); + } + const value = result.stdout.trim(); + return realpathSync(isAbsolute(value) ? value : resolve(cwd, value)); +} + +async function treeWorkControlDescriptor( + pi: ExtensionAPI, + cwd: string, +): Promise { + const commonDir = await gitPath(pi, cwd, "--git-common-dir"); + const descriptorPath = join(commonDir, "treework", "control.json"); + let descriptor: ControlDescriptor; + try { + descriptor = JSON.parse(readFileSync(descriptorPath, "utf8")) as ControlDescriptor; + } catch (error) { + throw new Error(`TreeWork control descriptor is unavailable at ${descriptorPath}: ${String(error)}`); + } + if ( + descriptor.version !== 1 || + !descriptor.project_id || + !descriptor.control_root || + !isAbsolute(descriptor.control_root) + ) { + throw new Error(`TreeWork control descriptor is invalid: ${descriptorPath}`); + } + const controlRoot = realpathSync(descriptor.control_root); + const controlCommonDir = await gitPath(pi, controlRoot, "--git-common-dir"); + if (controlCommonDir !== commonDir) { + throw new Error("TreeWork control descriptor does not belong to the current Git repository"); + } + return { ...descriptor, controlRoot, commonDir }; +} + +async function treeWorkControlRoot(pi: ExtensionAPI, cwd: string): Promise { + return (await treeWorkControlDescriptor(pi, cwd)).controlRoot; +} + +async function assertTreeWorkWorkspace( + pi: ExtensionAPI, + source: string, + target: string, +): Promise<{ target: string; controlRoot: string; kind: "control" | "branch" }> { + if (!isAbsolute(target)) throw new Error("TreeWork workspace switch requires an absolute path"); + const canonical = realpathSync(target); + const sourceDescriptor = await treeWorkControlDescriptor(pi, source); + const targetDescriptor = await treeWorkControlDescriptor(pi, canonical); + if ( + sourceDescriptor.commonDir !== targetDescriptor.commonDir || + sourceDescriptor.project_id !== targetDescriptor.project_id || + sourceDescriptor.controlRoot !== targetDescriptor.controlRoot + ) { + throw new Error("Refusing to switch across different TreeWork projects"); + } + + const status = await runTw(pi, canonical, ["check", "--brief"], undefined, 60_000); + if (status.code !== 0) { + throw new Error(commandOutput(status) || "TreeWork runtime rejected the target workspace"); + } + + if (canonical === targetDescriptor.controlRoot) { + return { target: canonical, controlRoot: canonical, kind: "control" }; + } + + const gitDir = await gitPath(pi, canonical, "--git-dir"); + const bindingPath = join(gitDir, "treework-branch.json"); + let binding: WorktreeBinding; + try { + binding = JSON.parse(readFileSync(bindingPath, "utf8")) as WorktreeBinding; + } catch (error) { + throw new Error(`Refusing to switch: invalid TreeWork branch binding at ${bindingPath}: ${String(error)}`); + } + if ( + binding.version !== 1 || + binding.project_id !== targetDescriptor.project_id || + !binding.branch || + !isAbsolute(binding.workspace) || + realpathSync(binding.workspace) !== canonical + ) { + throw new Error(`Refusing to switch: ${canonical} does not match its TreeWork branch binding`); + } + return { + target: canonical, + controlRoot: targetDescriptor.controlRoot, + kind: "branch", + }; +} + +async function resolveMcpWorkspace(pi: ExtensionAPI, cwd: string): Promise { + const descriptor = await treeWorkControlDescriptor(pi, cwd); + const status = await runTw(pi, cwd, ["check", "--brief"], undefined, 60_000); + if (status.code !== 0) throw new Error(commandOutput(status) || "TreeWork workspace is invalid"); + return descriptor.controlRoot; +} + +function requirePersistedSession(ctx: { sessionManager: { getSessionFile(): string | undefined } }): string { + const sessionFile = ctx.sessionManager.getSessionFile(); + if (!sessionFile || !existsSync(sessionFile)) { + throw new Error("TreeWork workspace handoff requires a persisted Pi session; restart without --no-session"); + } + return sessionFile; +} + +function prepareSessionFork(sourceSession: string, target: string): PreparedFork { + const targetSession = SessionManager.forkFrom(sourceSession, target); + targetSession.appendSessionInfo(`TreeWork · ${basename(target)}`); + const targetFile = targetSession.getSessionFile(); + if (!targetFile || !existsSync(targetFile)) { + throw new Error("TreeWork failed to prepare the target Pi session"); + } + return { targetFile, target }; +} + +async function switchPreparedSession( + ctx: ExtensionCommandContext, + prepared: PreparedFork, + kind: "control" | "branch", + handoffMessage: string, + resume: boolean, +): Promise { + const switched = await ctx.switchSession(prepared.targetFile, { + withSession: async (newCtx) => { + newCtx.ui.notify( + `TreeWork moved this conversation to the ${kind} workspace:\n${prepared.target}`, + "info", + ); + try { + await newCtx.sendMessage( + { + customType: "treework-handoff", + content: handoffMessage, + display: true, + }, + resume + ? { triggerTurn: true, deliverAs: "followUp" } + : { triggerTurn: false, deliverAs: "nextTurn" }, + ); + } catch (error) { + newCtx.ui.notify(`TreeWork switched workspaces, but could not queue the resume message: ${String(error)}`, "warning"); + } + }, + }); + if (switched.cancelled) { + rmSync(prepared.targetFile, { force: true }); + return false; + } + return true; +} + +async function pauseAfterFailedEnter( + pi: ExtensionAPI, + controlRoot: string, + reason: string, +): Promise { + const paused = await runTw( + pi, + controlRoot, + ["pause", "--reason", `Pi workspace handoff failed: ${reason}`], + undefined, + 60_000, + ); + return commandOutput(paused) || `tw pause exited ${paused.code}`; +} + +export default function treeWorkPiAdapter(pi: ExtensionAPI) { + const mcp = new TreeWorkMcpClient(pluginRoot, buildDir, adapterVersion); + let lastCheckNotice = ""; + + pi.on("resources_discover", () => ({ skillPaths: [skillRoot] })); + + pi.registerTool({ + name: "treework_recall", + label: "TreeWork Recall", + description: + "Recover one TreeWork branch from the committed projection, including documents, relationships, isolation, allowed actions, and blockers.", + parameters: Type.Object({ + workspace: Type.Optional(Type.String({ description: "TreeWork path; defaults to current Pi cwd" })), + branch: Type.Optional(Type.String({ description: "Branch path; defaults to the current TreeWork branch" })), + max_chars: Type.Optional(Type.Integer({ minimum: 1000, maximum: 50000 })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const workspace = await resolveMcpWorkspace(pi, resolve(params.workspace ?? ctx.cwd)); + const result = await mcp.callTool( + "treework_recall", + { + workspace, + ...(params.branch ? { branch: params.branch } : {}), + ...(params.max_chars ? { max_chars: params.max_chars } : {}), + }, + signal, + ); + const bounded = boundedText(result.content); + return { + content: bounded.content, + details: { + workspace, + branch: params.branch ?? null, + ...bounded.truncation, + }, + }; + }, + }); + + pi.registerTool({ + name: "treework_project_map", + label: "TreeWork Project Map", + description: + "Start or reuse TreeWork's read-only localhost Project Map without changing accepted project state.", + parameters: Type.Object({ + workspace: Type.Optional(Type.String({ description: "TreeWork path; defaults to current Pi cwd" })), + open: Type.Optional( + Type.Boolean({ description: "Explicitly open the returned localhost URL in the system browser" }), + ), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const workspace = await resolveMcpWorkspace(pi, resolve(params.workspace ?? ctx.cwd)); + const result = await mcp.callTool("treework_project_map", { workspace }, signal); + const url = result.structuredContent?.url; + if (params.open && typeof url === "string") { + const opener = platform() === "darwin" ? "open" : "xdg-open"; + const opened = await pi.exec(opener, [url], { signal, timeout: 10_000 }); + if (opened.code !== 0) throw new Error(commandOutput(opened) || `${opener} failed`); + } + const bounded = boundedText(result.content); + return { + content: bounded.content, + details: { + ...(result.structuredContent ?? {}), + opened: Boolean(params.open), + ...bounded.truncation, + }, + }; + }, + }); + + pi.registerTool({ + name: "treework_check", + label: "TreeWork Check", + description: "Run TreeWork's read-only consistency check against the authoritative control workspace.", + parameters: Type.Object({ + workspace: Type.Optional(Type.String({ description: "TreeWork path; defaults to current Pi cwd" })), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + const workspace = await resolveMcpWorkspace(pi, resolve(params.workspace ?? ctx.cwd)); + const result = await mcp.callTool("treework_check", { workspace }, signal); + const bounded = boundedText(result.content); + return { + content: bounded.content, + details: { + workspace, + ok: result.structuredContent?.ok, + ...bounded.truncation, + }, + }; + }, + }); + + pi.registerTool({ + name: "treework_tools", + label: "TreeWork Tools", + description: + "Load TreeWork read-only tools on demand. Use memory for Recall/Check, map for Project Map, or all only when both are needed. Enter and Return are explicit Pi commands taught by the TreeWork Skill.", + promptSnippet: "Load TreeWork memory or Project Map tools only when TreeWork is needed", + promptGuidelines: [ + "Use treework_tools before TreeWork operations when the required TreeWork tool is not active.", + ], + parameters: Type.Object({ + capability: StringEnum(["memory", "map", "all"] as const), + }), + async execute(_id, params) { + const active = pi.getActiveTools(); + const next = activateTreeWorkTools(active, params.capability); + const added = next.filter((name) => !active.includes(name)); + pi.setActiveTools(next); + return { + content: [ + { + type: "text" as const, + text: added.length + ? `Loaded TreeWork tools: ${added.join(", ")}` + : "Requested TreeWork tools are already active.", + }, + ], + details: { capability: params.capability, added }, + }; + }, + }); + + pi.on("session_start", () => { + pi.setActiveTools(resetTreeWorkTools(pi.getActiveTools())); + }); + + pi.on("tool_call", (event, ctx) => { + if (!findTreeWorkAncestor(ctx.cwd)) return undefined; + if (!shouldBlockProtectedTreeWorkAccess(event.toolName, event.input, ctx.cwd)) return undefined; + const reason = + "TreeWork guardrail: use supported TreeWork CLI/MCP surfaces for machine-owned state, events, and generated blocks; mutations occur only through tw transactions."; + if (ctx.hasUI) ctx.ui.notify(reason, "warning"); + return { block: true, reason }; + }); + + pi.on("agent_settled", async (_event, ctx) => { + if (!findTreeWorkAncestor(ctx.cwd)) return; + const result = await runTw(pi, ctx.cwd, ["check", "--brief"], undefined, 60_000); + const check = commandOutput(result); + if (result.code === 0 && check.includes("TreeWork check: 0 issue(s)")) { + lastCheckNotice = ""; + return; + } + const notice = `TreeWork stop check needs attention. ${check || `tw check exited ${result.code}`}`; + if (notice === lastCheckNotice) return; + lastCheckNotice = notice; + if (ctx.hasUI) ctx.ui.notify(notice, "warning"); + pi.sendMessage( + { customType: "treework-check", content: notice, display: true }, + { deliverAs: "nextTurn" }, + ); + }); + + pi.on("session_shutdown", () => { + mcp.close(); + }); + + pi.registerCommand("treework-enter", { + description: "Enter one accepted TreeWork branch and move this conversation into its managed worktree", + handler: async (args, ctx) => { + const request = parseEnterCommand(args); + await ctx.waitForIdle(); + const sourceSession = requirePersistedSession(ctx); + const sourceDescriptor = await treeWorkControlDescriptor(pi, ctx.cwd); + const sourceStatus = await runTw(pi, ctx.cwd, ["check", "--brief"], undefined, 60_000); + if (sourceStatus.code !== 0) { + throw new Error(commandOutput(sourceStatus) || "TreeWork source workspace is invalid"); + } + + const preview = await runTw( + pi, + ctx.cwd, + ["enter", request.branch, "--dry-run"], + undefined, + 120_000, + ); + const previewOutput = commandOutput(preview); + if (preview.code !== 0) throw new Error(previewOutput || `tw enter --dry-run exited ${preview.code}`); + const predicted = extractTreeWorkWorkspace(previewOutput); + if (!predicted) throw new Error(`tw enter --dry-run did not return a workspace:\n${previewOutput}`); + + let prepared: PreparedFork; + try { + prepared = prepareSessionFork(sourceSession, predicted); + } catch (error) { + throw new Error(`TreeWork Enter stopped before changing state: ${String(error)}`); + } + + const enterArgs = ["enter", request.branch]; + if (request.recall) enterArgs.push("--recall"); + const entered = await runTw(pi, ctx.cwd, enterArgs, undefined, 180_000); + const enteredOutput = commandOutput(entered); + if (entered.code !== 0) { + rmSync(prepared.targetFile, { force: true }); + throw new Error(enteredOutput || `tw enter exited ${entered.code}`); + } + const workspace = extractTreeWorkWorkspace(enteredOutput); + if (!workspace || resolve(workspace) !== resolve(predicted)) { + rmSync(prepared.targetFile, { force: true }); + const pause = await pauseAfterFailedEnter(pi, sourceDescriptor.controlRoot, "workspace prediction mismatch"); + throw new Error(`TreeWork Enter paused after an unsafe handoff: ${pause}`); + } + + try { + const validated = await assertTreeWorkWorkspace(pi, ctx.cwd, workspace); + const switched = await switchPreparedSession( + ctx, + prepared, + validated.kind, + `TreeWork Enter completed for ${request.branch}.\n\n${enteredOutput}`, + request.resume, + ); + if (switched) return; + const pause = await pauseAfterFailedEnter(pi, sourceDescriptor.controlRoot, "Pi session switch was cancelled"); + ctx.ui.notify(`TreeWork Enter was paused because Pi cancelled the workspace switch.\n${pause}`, "warning"); + } catch (error) { + rmSync(prepared.targetFile, { force: true }); + const pause = await pauseAfterFailedEnter(pi, sourceDescriptor.controlRoot, String(error)); + throw new Error(`TreeWork Enter could not complete its Pi handoff and was paused. ${pause}`); + } + }, + }); + + pi.registerCommand("treework-return", { + description: "Return this conversation from a branch worktree to its validated TreeWork control workspace", + handler: async (args, ctx) => { + const request = parseReturnCommand(args); + await ctx.waitForIdle(); + const sourceSession = requirePersistedSession(ctx); + const controlRoot = await treeWorkControlRoot(pi, ctx.cwd); + if (realpathSync(ctx.cwd) === controlRoot) { + ctx.ui.notify(`Already in TreeWork control workspace: ${controlRoot}`, "info"); + return; + } + const validated = await assertTreeWorkWorkspace(pi, ctx.cwd, controlRoot); + const prepared = prepareSessionFork(sourceSession, validated.target); + const switched = await switchPreparedSession( + ctx, + prepared, + "control", + `TreeWork returned this conversation to the control workspace: ${validated.target}`, + request.resume, + ); + if (!switched) ctx.ui.notify("Pi cancelled the TreeWork return; the branch session remains active.", "warning"); + }, + }); + + pi.registerCommand("treework-adapter", { + description: "Show TreeWork Pi adapter and runtime status", + handler: async (_args, ctx) => { + const version = await runTw(pi, ctx.cwd, ["version"], undefined, 120_000); + const state = findTreeWorkAncestor(ctx.cwd) ? "initialized" : "not initialized"; + const text = [ + `TreeWork Pi adapter ${adapterVersion}: ${version.code === 0 ? version.stdout.trim() : "runtime unavailable"}`, + `Workspace: ${ctx.cwd}`, + `Project state: ${state}`, + `Active TreeWork tools: ${pi.getActiveTools().filter((name) => TREEWORK_TOOLS.includes(name)).join(", ") || "deferred"}`, + `Skill: ${skillRoot}`, + ].join("\n"); + ctx.ui.notify(text, version.code === 0 ? "info" : "error"); + }, + }); +} diff --git a/adapters/pi/mcp-client.mjs b/adapters/pi/mcp-client.mjs new file mode 100644 index 0000000..0e9ad50 --- /dev/null +++ b/adapters/pi/mcp-client.mjs @@ -0,0 +1,202 @@ +import { spawn } from "node:child_process"; + +export class TreeWorkMcpClient { + constructor(pluginRoot, buildDir, clientVersion = "unknown") { + this.pluginRoot = pluginRoot; + this.buildDir = buildDir; + this.clientVersion = clientVersion; + this.process = undefined; + this.queue = []; + this.waiters = []; + this.stdoutBuffer = ""; + this.stderrTail = ""; + this.nextId = 1; + this.initialized = false; + this.serial = Promise.resolve(); + } + + rejectWaiters(error) { + for (const waiter of this.waiters.splice(0)) waiter.reject(error); + } + + startProcess() { + if (this.process && this.process.exitCode === null) return; + this.queue = []; + this.stdoutBuffer = ""; + this.stderrTail = ""; + const child = spawn("bash", ["./scripts/start-mcp.sh"], { + cwd: this.pluginRoot, + env: { + ...process.env, + TREEWORK_PLUGIN_ROOT: this.pluginRoot, + TREEWORK_BUILD_DIR: this.buildDir, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + this.process = child; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + this.stdoutBuffer += chunk; + let newline = this.stdoutBuffer.indexOf("\n"); + while (newline >= 0) { + const rawLine = this.stdoutBuffer.slice(0, newline); + this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + const waiter = this.waiters.shift(); + if (waiter) waiter.resolve(line); + else this.queue.push(line); + newline = this.stdoutBuffer.indexOf("\n"); + } + }); + child.stderr.on("data", (chunk) => { + this.stderrTail = `${this.stderrTail}${String(chunk)}`.slice(-12000); + }); + child.on("error", (cause) => { + const error = new Error(`TreeWork MCP server failed to start: ${cause.message}`, { cause }); + this.rejectWaiters(error); + this.initialized = false; + }); + child.on("exit", (code, signal) => { + const error = new Error( + `TreeWork MCP server exited (${signal ?? code ?? "unknown"})${ + this.stderrTail ? `: ${this.stderrTail.trim()}` : "" + }`, + ); + this.rejectWaiters(error); + this.initialized = false; + }); + } + + nextLine(timeoutMs, signal) { + if (signal?.aborted) return Promise.reject(new Error("TreeWork MCP request cancelled")); + const queued = this.queue.shift(); + if (queued !== undefined) return Promise.resolve(queued); + + return new Promise((resolve, reject) => { + let settled = false; + let timer; + const finish = (callback) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + const index = this.waiters.indexOf(waiter); + if (index >= 0) this.waiters.splice(index, 1); + callback(); + }; + const waiter = { + resolve: (line) => finish(() => resolve(line)), + reject: (error) => finish(() => reject(error)), + }; + const onAbort = () => { + finish(() => reject(new Error("TreeWork MCP request cancelled"))); + this.close(new Error("TreeWork MCP request cancelled")); + }; + timer = setTimeout(() => { + const error = new Error(`TreeWork MCP request timed out after ${timeoutMs}ms`); + finish(() => reject(error)); + this.close(error); + }, timeoutMs); + signal?.addEventListener("abort", onAbort, { once: true }); + this.waiters.push(waiter); + }); + } + + async rawRequest(method, params, timeoutMs, signal) { + if (signal?.aborted) throw new Error("TreeWork MCP request cancelled"); + const child = this.process; + if (!child || child.exitCode !== null) throw new Error("TreeWork MCP server is not running"); + const id = this.nextId++; + const payload = { jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }; + child.stdin.write(`${JSON.stringify(payload)}\n`); + const deadline = Date.now() + timeoutMs; + + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`TreeWork MCP request timed out after ${timeoutMs}ms`); + const line = await this.nextLine(remaining, signal); + let response; + try { + response = JSON.parse(line); + } catch (error) { + throw new Error(`TreeWork MCP returned invalid JSON: ${String(error)}`); + } + if (response.id === undefined) continue; + if (response.id !== id) { + throw new Error(`TreeWork MCP response id mismatch: expected ${id}, received ${response.id}`); + } + if (response.error) { + throw new Error( + `TreeWork MCP error ${response.error.code ?? ""}: ${response.error.message ?? "unknown error"}`.trim(), + ); + } + return response.result; + } + } + + notify(method) { + const child = this.process; + if (!child || child.exitCode !== null) throw new Error("TreeWork MCP server is not running"); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method })}\n`); + } + + async ensureInitialized(signal) { + if (signal?.aborted) throw new Error("TreeWork MCP request cancelled"); + if (this.initialized && this.process?.exitCode === null) return; + this.startProcess(); + await this.rawRequest( + "initialize", + { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "treework-pi-adapter", version: this.clientVersion }, + }, + 60_000, + signal, + ); + this.notify("notifications/initialized"); + this.initialized = true; + } + + async callTool(name, args, signal) { + const execute = async () => { + if (signal?.aborted) throw new Error("TreeWork MCP request cancelled"); + await this.ensureInitialized(signal); + const result = await this.rawRequest( + "tools/call", + { name, arguments: args }, + name === "treework_project_map" ? 330_000 : 60_000, + signal, + ); + if (!result || !Array.isArray(result.content)) { + throw new Error(`TreeWork MCP tool ${name} returned an invalid result`); + } + if (result.isError) { + const text = result.content.map((part) => part.text).join("\n"); + throw new Error(text || `TreeWork MCP tool ${name} failed`); + } + return result; + }; + + const pending = this.serial.then(execute, execute); + this.serial = pending.then( + () => undefined, + () => undefined, + ); + return pending; + } + + close(reason = new Error("TreeWork MCP client closed")) { + const child = this.process; + this.process = undefined; + this.initialized = false; + this.queue = []; + this.stdoutBuffer = ""; + this.rejectWaiters(reason); + if (!child || child.exitCode !== null) return; + child.stdin.end(); + setTimeout(() => { + if (child.exitCode === null) child.kill("SIGTERM"); + }, 1000).unref(); + } +} diff --git a/adapters/pi/tests/core.test.mjs b/adapters/pi/tests/core.test.mjs new file mode 100644 index 0000000..f30326c --- /dev/null +++ b/adapters/pi/tests/core.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + activateTreeWorkTools, + containsGeneratedTreeWorkMarker, + extractTreeWorkWorkspace, + isProtectedTreeWorkPath, + resetTreeWorkTools, + shouldBlockProtectedTreeWorkAccess, +} from "../core.mjs"; + +test("protects machine-owned TreeWork state, including symlink aliases", () => { + const temp = mkdtempSync(join(tmpdir(), "treework-pi-path-")); + try { + const state = join(temp, ".TreeWork", "state"); + mkdirSync(state, { recursive: true }); + symlinkSync(state, join(temp, "state-link")); + assert.equal(isProtectedTreeWorkPath(".TreeWork/state/project.json", temp), true); + assert.equal(isProtectedTreeWorkPath(join(temp, ".TreeWork", "events.jsonl"), temp), true); + assert.equal(isProtectedTreeWorkPath("state-link/new-state.json", temp), true); + assert.equal( + shouldBlockProtectedTreeWorkAccess( + "bash", + { command: "printf hacked > state-link/project.json" }, + temp, + ), + true, + ); + assert.equal( + shouldBlockProtectedTreeWorkAccess( + "bash", + { command: "printf hacked > .TreeWork/st?te/project.json" }, + temp, + ), + true, + ); + assert.equal(isProtectedTreeWorkPath(".TreeWork/progress.md", temp), false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); + +test("protects generated TreeWork blocks", () => { + assert.equal(containsGeneratedTreeWorkMarker(""), true); + assert.equal(containsGeneratedTreeWorkMarker("ordinary markdown"), false); +}); + +test("blocks explicit file and common split-path shell access to protected internals", () => { + assert.equal( + shouldBlockProtectedTreeWorkAccess("read", { path: ".TreeWork/state/project.json" }, "/repo"), + true, + ); + assert.equal( + shouldBlockProtectedTreeWorkAccess("edit", { path: ".TreeWork/state/project.json" }, "/repo"), + true, + ); + assert.equal( + shouldBlockProtectedTreeWorkAccess( + "write", + { path: ".TreeWork/progress.md", content: "ok" }, + "/repo", + ), + false, + ); + assert.equal( + shouldBlockProtectedTreeWorkAccess( + "bash", + { command: "cd .TreeWork && cat state/project.json" }, + "/repo", + ), + true, + ); + assert.equal( + shouldBlockProtectedTreeWorkAccess("bash", { command: "rm .TreeWork/events.jsonl" }, "/repo"), + true, + ); + assert.equal( + shouldBlockProtectedTreeWorkAccess("bash", { command: "cargo test" }, "/repo"), + false, + ); +}); + +test("keeps one loader active and expands only the requested capability", () => { + assert.deepEqual( + resetTreeWorkTools(["read", "treework_recall", "treework_check"]), + ["read", "treework_tools"], + ); + assert.deepEqual( + activateTreeWorkTools(["read", "treework_tools"], "map"), + ["read", "treework_tools", "treework_project_map"], + ); + assert.deepEqual( + activateTreeWorkTools(["treework_tools"], "memory"), + ["treework_tools", "treework_recall", "treework_check"], + ); +}); + +test("extracts only absolute workspaces from enter output", () => { + assert.equal( + extractTreeWorkWorkspace("Isolation:\n workspace: /tmp/project-branch\n status: created\n"), + "/tmp/project-branch", + ); + assert.equal(extractTreeWorkWorkspace("workspace: relative/path"), undefined); +}); diff --git a/adapters/pi/tests/mcp-client.test.mjs b/adapters/pi/tests/mcp-client.test.mjs new file mode 100644 index 0000000..700394b --- /dev/null +++ b/adapters/pi/tests/mcp-client.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { TreeWorkMcpClient } from "../mcp-client.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(here, "../../.."); +const pluginRoot = join(repositoryRoot, "plugins", "treework"); +const tw = join(pluginRoot, "skills", "treework", "scripts", "tw"); + +test("Pi MCP client calls the shipped TreeWork server", { timeout: 180_000 }, async () => { + const temp = mkdtempSync(join(tmpdir(), "treework-pi-mcp-")); + const workspace = join(temp, "workspace"); + const buildDir = join(temp, "build"); + const env = { + ...process.env, + TREEWORK_PLUGIN_ROOT: pluginRoot, + TREEWORK_BUILD_DIR: buildDir, + }; + mkdirSync(workspace); + execFileSync(tw, ["init"], { cwd: workspace, env, stdio: "pipe" }); + + const client = new TreeWorkMcpClient(pluginRoot, buildDir, "test"); + try { + const result = await client.callTool("treework_check", { workspace }); + assert.equal(result.isError, false); + assert.equal(result.structuredContent?.ok, true); + assert.match(result.content[0]?.text ?? "", /TreeWork check:/); + } finally { + client.close(); + await delay(1200); + rmSync(temp, { recursive: true, force: true }); + } +}); + +test("MCP client skips notifications and preserves Unicode line separators", async () => { + const temp = mkdtempSync(join(tmpdir(), "treework-pi-fake-mcp-")); + const scripts = join(temp, "scripts"); + mkdirSync(scripts); + const launcher = join(scripts, "start-mcp.sh"); + const server = join(temp, "server.py"); + writeFileSync(launcher, '#!/usr/bin/env bash\nexec python3 "$(dirname "$0")/../server.py"\n'); + chmodSync(launcher, 0o755); + writeFileSync( + server, + `import json, sys +for raw in sys.stdin: + request = json.loads(raw) + if request.get("method") == "initialize": + print(json.dumps({"jsonrpc":"2.0","method":"server/ready","params":{}}, ensure_ascii=False), flush=True) + print(json.dumps({"jsonrpc":"2.0","id":request["id"],"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fake","version":"1"}}}, ensure_ascii=False), flush=True) + elif request.get("method") == "tools/call": + print(json.dumps({"jsonrpc":"2.0","id":request["id"],"result":{"content":[{"type":"text","text":"before\\u2028after"}],"isError":False}}, ensure_ascii=False), flush=True) +`, + ); + + const client = new TreeWorkMcpClient(temp, join(temp, "build"), "test"); + try { + const result = await client.callTool("fake", {}); + assert.equal(result.content[0].text, "before\u2028after"); + } finally { + client.close(); + await delay(50); + rmSync(temp, { recursive: true, force: true }); + } +}); + +test("MCP client rejects pre-cancelled calls before spawning", async () => { + const controller = new AbortController(); + controller.abort(); + const client = new TreeWorkMcpClient("/path/that/does/not/exist", "/tmp/unused", "test"); + await assert.rejects(client.callTool("fake", {}, controller.signal), /cancelled/); +}); + +test("MCP client reports spawn failures", async () => { + const client = new TreeWorkMcpClient("/path/that/does/not/exist", "/tmp/unused", "test"); + await assert.rejects(client.callTool("fake", {}), /failed to start|exited/); + client.close(); +}); diff --git a/docs/README.md b/docs/README.md index 593d881..9a4de4c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,12 @@ The installed Skill owns workflow guidance: These files live under `plugins/treework/skills/treework/references/`. +## Host Adapters + +- [TreeWork for Pi](../adapters/pi/README.md) defines Pi installation, lazy + tools, lifecycle guardrails, cwd-bound conversation handoff, verification, + and rollback. + ## Product Maintainers - [Project Map](product/project-map.md) defines Map, Dependency, Replay, diff --git a/docs/development.md b/docs/development.md index 3691fb9..2349784 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,9 +2,11 @@ ## Layout -The installable plugin is `plugins/treework/`. Runtime source and -bundled assets stay inside that directory. Project Map TypeScript source, -repository tests, and maintainer documents remain outside it. +The installable Codex plugin and shared TreeWork runtime are under +`plugins/treework/`. The focused Pi package is declared by the root +`package.json` and implemented under `adapters/pi/`; it reuses the shared Skill +and MCP server rather than copying them. Project Map TypeScript source, +repository tests, and maintainer documents remain outside the plugin. ## Rust Runtime @@ -48,7 +50,9 @@ python3 scripts/measure_project_map_performance.py --mode verify \ python3 scripts/stress_project_map.py --branches 750 --relations 1500 ``` -## Plugin Surfaces +## Host Surfaces + +Codex plugin checks: ```bash python3 scripts/check_hooks.py @@ -57,6 +61,22 @@ python3 scripts/check_packaging.py python3 scripts/test_check_activation.py ``` +Pi adapter checks require Node.js 22+. The Pi executable is optional locally; +set `TREEWORK_REQUIRE_PI=1` to make its absence fail as it does in CI. + +```bash +node --test adapters/pi/tests/*.test.mjs +python3 scripts/check_pi_adapter.py +python3 scripts/check_pi_workspace_switch.py +``` + +The round-trip test initializes a temporary TreeWork repository and drives Pi +from a repository subdirectory in offline RPC mode. It exercises the deferred +Enter command, real managed-worktree switch, and Return command; verifies that +history and the parent-session chain survive; and proves a switch cancelled by +another extension recovers the entered branch to `paused` without leaving an +orphan Pi session. + Plugin and Skill schema validation requires PyYAML: ```bash diff --git a/docs/releasing.md b/docs/releasing.md index 1cbe058..cc26650 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -4,6 +4,7 @@ 1. Update the version in: - `plugins/treework/.codex-plugin/plugin.json` + - `package.json` (Pi package; validation requires the same version) - `plugins/treework/crates/treework-cli/Cargo.toml` - `plugins/treework/Cargo.lock` - `project-map-ui/package.json` @@ -41,6 +42,15 @@ version: python3 scripts/check_activation.py ``` +Install the repository as a local Pi package in an isolated or disposable Pi +agent directory, verify `/treework-adapter`, then remove it again. The automated +package-load and rollback check plus the real offline session round-trip are: + +```bash +TREEWORK_REQUIRE_PI=1 python3 scripts/check_pi_adapter.py +TREEWORK_REQUIRE_PI=1 python3 scripts/check_pi_workspace_switch.py +``` + ## Publish After the installed candidate passes: diff --git a/package.json b/package.json new file mode 100644 index 0000000..5e0ea1c --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "treework-pi-adapter", + "version": "0.1.7", + "private": true, + "description": "Pi host adapter for TreeWork's state-native project workflow.", + "keywords": ["pi-package", "treework", "coding-agent"], + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": ">=0.84.0", + "typebox": "*" + }, + "pi": { + "extensions": ["./adapters/pi/index.ts"], + "skills": ["./plugins/treework/skills/treework"] + } +} diff --git a/plugins/treework/crates/treework-cli/src/main.rs b/plugins/treework/crates/treework-cli/src/main.rs index b2523b8..78ccb74 100644 --- a/plugins/treework/crates/treework-cli/src/main.rs +++ b/plugins/treework/crates/treework-cli/src/main.rs @@ -1347,7 +1347,7 @@ fn apply_declarative_tree(root: &Path) -> AppResult<()> { ); if first_tree { println!( - "First Tree accepted. Call `treework_project_map` for `{}` and open its localhost URL in the Codex in-app browser.", + "First Tree accepted. Call `treework_project_map` for `{}` and use the current host adapter's browser handoff (Codex uses the Codex in-app browser; Pi opens a system browser only when explicitly requested).", root.display() ); } diff --git a/plugins/treework/skills/treework/SKILL.md b/plugins/treework/skills/treework/SKILL.md index c32d9fc..db09537 100644 --- a/plugins/treework/skills/treework/SKILL.md +++ b/plugins/treework/skills/treework/SKILL.md @@ -100,6 +100,12 @@ workspace path. A CLI process cannot change the parent Agent's cwd. After Enter, run every filesystem and terminal action for that branch from the printed workspace path; merely running Enter from the control workspace is not enough. +With the Pi host adapter, ask the user to invoke `/treework-enter ` +instead of running `tw enter` in Bash. Session replacement is an explicit Pi +host command, not an Agent tool call. The command waits for active tools, +performs Enter, and moves the complete conversation into the worktree. Before a +control-workspace transition, ask the user to invoke `/treework-return`. + - **Pause:** park unfinished work after updating branch documents and committing what should be durable. The managed worktree and binding stay in place for later reuse. diff --git a/plugins/treework/skills/treework/references/02-build-tree.md b/plugins/treework/skills/treework/references/02-build-tree.md index 2595409..c8b618a 100644 --- a/plugins/treework/skills/treework/references/02-build-tree.md +++ b/plugins/treework/skills/treework/references/02-build-tree.md @@ -91,11 +91,13 @@ layout. The Agent never operates Project Map refresh machinery. The first successful Apply also completes a user-facing handoff. When the Tree revision moves from 0 to 1, call `treework_project_map` with the absolute -workspace path and open the returned localhost URL in the Codex in-app browser. -Do not substitute the system browser, and do not stop after merely printing the -URL when the in-app browser is available. Later Apply transactions do not open -another tab; an open panel updates itself, and a closed panel is relaunched only -on an explicit user request. +workspace path and use the current host adapter's browser handoff. Codex opens +the returned localhost URL in the Codex in-app browser; Pi returns the URL and +opens the system browser only when explicitly requested. Do not silently +substitute one host's browser surface for another, and do not stop after merely +printing the URL when an in-app browser is available. Later Apply transactions +do not open another tab; an open panel updates itself, and a closed panel is +relaunched only on an explicit user request. ## Boundaries diff --git a/scripts/check_pi_adapter.py b/scripts/check_pi_adapter.py new file mode 100755 index 0000000..0ebd88a --- /dev/null +++ b/scripts/check_pi_adapter.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Validate the TreeWork Pi package and load it through Pi when available.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from _paths import REPOSITORY_ROOT + +PACKAGE = REPOSITORY_ROOT / "package.json" +ADAPTER = REPOSITORY_ROOT / "adapters" / "pi" +EXTENSION = ADAPTER / "index.ts" +SKILL = REPOSITORY_ROOT / "plugins" / "treework" / "skills" / "treework" + + +def fail(message: str) -> None: + print(f"fail: {message}") + raise SystemExit(1) + + +def ok(message: str) -> None: + print(f"ok: {message}") + + +def check_manifest() -> None: + try: + package = json.loads(PACKAGE.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError) as error: + fail(f"invalid Pi package manifest: {error}") + if package.get("name") != "treework-pi-adapter": + fail("root package.json must identify treework-pi-adapter") + pi = package.get("pi") + if not isinstance(pi, dict): + fail("root package.json must contain a pi manifest") + if pi.get("extensions") != ["./adapters/pi/index.ts"]: + fail("Pi manifest must load the focused adapter extension") + if pi.get("skills") != ["./plugins/treework/skills/treework"]: + fail("Pi manifest must reuse the shipped TreeWork skill") + if package.get("license") != "MIT": + fail("Pi adapter must retain the repository MIT license") + plugin = json.loads( + (REPOSITORY_ROOT / "plugins" / "treework" / ".codex-plugin" / "plugin.json").read_text( + encoding="utf-8" + ) + ) + if package.get("version") != plugin.get("version"): + fail("Pi adapter and shared TreeWork runtime versions must match") + ok("Pi package manifest") + + +def check_adapter_surface() -> None: + required = [ + EXTENSION, + ADAPTER / "core.mjs", + ADAPTER / "mcp-client.mjs", + ADAPTER / "README.md", + ADAPTER / "tests" / "core.test.mjs", + ADAPTER / "tests" / "mcp-client.test.mjs", + SKILL / "SKILL.md", + ] + missing = [str(path.relative_to(REPOSITORY_ROOT)) for path in required if not path.is_file()] + if missing: + fail(f"Pi adapter is missing required files: {missing}") + source = EXTENSION.read_text(encoding="utf-8") + contracts = [ + 'pi.on("tool_call"', + 'pi.on("agent_settled"', + 'pi.on("session_shutdown"', + 'name: "treework_tools"', + 'name: "treework_recall"', + 'name: "treework_project_map"', + 'name: "treework_check"', + 'pi.registerCommand("treework-enter"', + 'pi.registerCommand("treework-return"', + "SessionManager.forkFrom", + ] + missing_contracts = [contract for contract in contracts if contract not in source] + if missing_contracts: + fail(f"Pi adapter is missing compatibility contracts: {missing_contracts}") + executable_sources = "\n".join( + path.read_text(encoding="utf-8") + for path in ADAPTER.rglob("*") + if path.is_file() and path.suffix in {".ts", ".mjs"} + ) + if "auth.json" in executable_sources or "OPENAI_API_KEY" in executable_sources: + fail("Pi adapter must not read Codex credentials or API keys") + ok("Pi adapter compatibility surface") + + +def check_node_tests() -> None: + result = subprocess.run( + [ + "node", + "--test", + "adapters/pi/tests/core.test.mjs", + "adapters/pi/tests/mcp-client.test.mjs", + ], + cwd=REPOSITORY_ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if result.returncode != 0: + fail(f"Pi adapter Node tests failed:\n{result.stdout}") + ok("Pi adapter guardrail and MCP integration tests") + + +def pi_executable() -> str | None: + pi_bin = shutil.which("pi") + if not pi_bin and os.environ.get("TREEWORK_REQUIRE_PI") == "1": + fail("Pi executable is required but not available") + return pi_bin + + +def run_pi_rpc_load( + pi_bin: str, + extra_args: list[str], + env: dict[str, str], +) -> set[str]: + result = subprocess.run( + [pi_bin, "--mode", "rpc", "--no-session", *extra_args], + cwd=REPOSITORY_ROOT, + env=env, + input='{"id":"commands","type":"get_commands"}\n', + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=60, + ) + combined = f"{result.stdout}\n{result.stderr}".lower() + if result.returncode != 0 or "failed to load extension" in combined: + fail(f"Pi failed to load the adapter:\n{result.stdout}\n{result.stderr}") + responses = [] + for line in result.stdout.splitlines(): + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if message.get("type") == "response" and message.get("id") == "commands": + responses.append(message) + if len(responses) != 1 or not responses[0].get("success"): + fail(f"Pi did not answer the adapter command probe:\n{result.stdout}\n{result.stderr}") + return { + command.get("name") + for command in responses[0].get("data", {}).get("commands", []) + if isinstance(command.get("name"), str) + } + + +def check_pi_load(pi_bin: str | None) -> None: + if not pi_bin: + print("skip: Pi executable unavailable; structural and integration checks completed") + return + env = os.environ.copy() + env["PI_OFFLINE"] = "1" + commands = run_pi_rpc_load( + pi_bin, + ["--no-skills", "--no-extensions", "-e", str(EXTENSION), "--skill", str(SKILL)], + env, + ) + if not {"treework-enter", "treework-return", "skill:treework"} <= commands: + fail(f"Pi adapter probe is missing commands or the shared skill: {sorted(commands)}") + ok("Pi runtime loads adapter and shared TreeWork skill") + + +def check_pi_package_install(pi_bin: str | None) -> None: + if not pi_bin: + return + with tempfile.TemporaryDirectory(prefix="treework-pi-package-") as temp_name: + agent_dir = Path(temp_name) / "agent" + env = os.environ.copy() + env["PI_OFFLINE"] = "1" + env["PI_CODING_AGENT_DIR"] = str(agent_dir) + install = subprocess.run( + [pi_bin, "install", str(REPOSITORY_ROOT)], + cwd=REPOSITORY_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=60, + ) + if install.returncode != 0: + fail(f"Pi local package install failed:\n{install.stdout}\n{install.stderr}") + settings_path = agent_dir / "settings.json" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + packages = settings.get("packages", []) + recorded = any( + isinstance(source, str) + and (REPOSITORY_ROOT / source).resolve() == REPOSITORY_ROOT.resolve() + for source in packages + ) + if not recorded: + fail(f"Pi did not record the TreeWork package source: {packages}") + commands = run_pi_rpc_load(pi_bin, [], env) + if not {"treework-enter", "treework-return", "skill:treework"} <= commands: + fail(f"installed Pi package is missing commands or the shared skill: {sorted(commands)}") + if (agent_dir / "cache" / "treework").exists(): + fail("loading the Pi package eagerly created the TreeWork runtime cache") + remove = subprocess.run( + [pi_bin, "remove", str(REPOSITORY_ROOT)], + cwd=REPOSITORY_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=60, + ) + if remove.returncode != 0: + fail(f"Pi local package rollback failed:\n{remove.stdout}\n{remove.stderr}") + settings = json.loads(settings_path.read_text(encoding="utf-8")) + still_enabled = any( + isinstance(source, str) + and (REPOSITORY_ROOT / source).resolve() == REPOSITORY_ROOT.resolve() + for source in settings.get("packages", []) + ) + if still_enabled: + fail("Pi remove left the TreeWork package enabled") + ok("Pi package installs, loads, and rolls back reversibly") + + +def main() -> None: + check_manifest() + check_adapter_surface() + check_node_tests() + pi_bin = pi_executable() + check_pi_load(pi_bin) + check_pi_package_install(pi_bin) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_pi_workspace_switch.py b/scripts/check_pi_workspace_switch.py new file mode 100755 index 0000000..e9662d6 --- /dev/null +++ b/scripts/check_pi_workspace_switch.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Exercise Pi's real session handoff across a TreeWork managed worktree.""" + +from __future__ import annotations + +import json +import os +import queue +import re +import shutil +import subprocess +import tempfile +import threading +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from _paths import PLUGIN_ROOT, REPOSITORY_ROOT + +TW = PLUGIN_ROOT / "skills" / "treework" / "scripts" / "tw" +EXTENSION = REPOSITORY_ROOT / "adapters" / "pi" / "index.ts" + + +def fail(message: str) -> None: + print(f"fail: {message}") + raise SystemExit(1) + + +def run(command: list[str], cwd: Path, env: dict[str, str] | None = None) -> str: + result = subprocess.run( + command, + cwd=cwd, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=180, + ) + if result.returncode != 0: + fail( + f"command failed ({' '.join(command)}):\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return f"{result.stdout}\n{result.stderr}" + + +def prepare_treework(control: Path, build_dir: Path) -> Path: + control.mkdir() + run(["git", "init", "-q", "-b", "main"], control) + run(["git", "config", "user.name", "TreeWork Pi Test"], control) + run(["git", "config", "user.email", "treework-pi@example.invalid"], control) + (control / "src").mkdir() + (control / "src" / "baseline.txt").write_text("baseline\n", encoding="utf-8") + run(["git", "add", "src/baseline.txt"], control) + run(["git", "commit", "-q", "-m", "baseline"], control) + + env = os.environ.copy() + env["TREEWORK_PLUGIN_ROOT"] = str(PLUGIN_ROOT) + env["TREEWORK_BUILD_DIR"] = str(build_dir) + run([str(TW), "init"], control, env) + run([str(TW), "align", "end"], control, env) + run([str(TW), "tree", "start"], control, env) + (control / ".TreeWork" / "tree.yaml").write_text( + """version: 1 +tree: + id: root + title: Pi Adapter Test + purpose: Verify TreeWork workspace handoff in Pi. + spec: spec.md + children: + - id: adapter + title: Pi Adapter + purpose: Exercise a managed TreeWork branch worktree. + spec: branches/adapter/spec.md +""", + encoding="utf-8", + ) + run([str(TW), "tree", "apply"], control, env) + run(["git", "add", ".TreeWork"], control) + run(["git", "commit", "-q", "-m", "accept TreeWork test tree"], control) + preview = run([str(TW), "enter", "adapter", "--dry-run"], control, env) + match = re.search(r"^\s*workspace:\s*(.+?)\s*$", preview, re.MULTILINE) + if not match: + fail(f"tw enter --dry-run did not report a workspace:\n{preview}") + return Path(match.group(1)).resolve() + + +class RpcClient: + def __init__(self, process: subprocess.Popen[str]) -> None: + self.process = process + self.lines: queue.Queue[str] = queue.Queue() + self.stderr: list[str] = [] + self.events: list[str] = [] + self.reader = threading.Thread(target=self._read_stdout, daemon=True) + self.err_reader = threading.Thread(target=self._read_stderr, daemon=True) + self.reader.start() + self.err_reader.start() + + def _read_stdout(self) -> None: + assert self.process.stdout is not None + for line in self.process.stdout: + stripped = line.rstrip("\n") + self.events.append(stripped) + self.lines.put(stripped) + + def _read_stderr(self) -> None: + assert self.process.stderr is not None + for line in self.process.stderr: + self.stderr.append(line.rstrip("\n")) + + def request(self, request: dict[str, Any], timeout: float = 30) -> dict[str, Any]: + assert self.process.stdin is not None + request_id = request.setdefault("id", f"req-{time.monotonic_ns()}") + self.process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n") + self.process.stdin.flush() + deadline = time.monotonic() + timeout + observed: list[str] = [] + while time.monotonic() < deadline: + try: + raw = self.lines.get(timeout=max(0.01, deadline - time.monotonic())) + except queue.Empty: + break + observed.append(raw) + try: + message = json.loads(raw) + except json.JSONDecodeError: + continue + if message.get("type") == "response" and message.get("id") == request_id: + if not message.get("success"): + fail(f"Pi RPC request failed: {message}\nobserved={observed}\nstderr={self.stderr}") + return message + fail( + f"timed out waiting for Pi RPC response to {request}\n" + f"observed={observed}\nstderr={self.stderr}" + ) + raise AssertionError("unreachable") + + +def session_header(session_file: str) -> dict[str, Any]: + with Path(session_file).open(encoding="utf-8") as handle: + return json.loads(handle.readline()) + + +def write_source_session(path: Path, cwd: Path) -> None: + path.write_text( + json.dumps( + { + "type": "session", + "version": 3, + "id": str(uuid.uuid4()), + "timestamp": datetime.now(timezone.utc).isoformat(), + "cwd": str(cwd.resolve()), + }, + separators=(",", ":"), + ) + + "\n", + encoding="utf-8", + ) + + +def branch_record(control: Path, branch: str) -> dict[str, Any]: + state = json.loads((control / ".TreeWork" / "state" / "branches.json").read_text()) + return next(item for item in state["branches"] if item["path"] == branch) + + +def wait_for_session( + client: RpcClient, + expected_cwd: Path, + previous_file: str, + timeout: float = 20, +) -> tuple[dict[str, Any], dict[str, Any]]: + deadline = time.monotonic() + timeout + last_state: dict[str, Any] = {} + last_header: dict[str, Any] | None = None + while time.monotonic() < deadline: + state = client.request({"type": "get_state"})["data"] + last_state = state + session_file = state.get("sessionFile") + if session_file and Path(session_file).is_file(): + header = session_header(session_file) + last_header = header + if session_file != previous_file and Path(header.get("cwd", "")).resolve() == expected_cwd.resolve(): + return state, header + time.sleep(0.1) + fail( + f"Pi did not finish switching to {expected_cwd}; previous={previous_file}; " + f"last state={last_state}; last header={last_header}; " + f"first events={client.events[:30]}; last events={client.events[-20:]}; " + f"stderr={client.stderr}" + ) + raise AssertionError("unreachable") + + +def main() -> None: + pi_bin = shutil.which("pi") + if not pi_bin: + if os.environ.get("TREEWORK_REQUIRE_PI") == "1": + fail("Pi executable is required but unavailable") + print("skip: Pi executable unavailable; workspace-switch integration was not run") + return + + with tempfile.TemporaryDirectory(prefix="treework-pi-switch-") as temp_name: + temp = Path(temp_name) + control = temp / "control" + branch = prepare_treework(control, temp / "treework-build") + source_cwd = control / "src" + session_dir = temp / "sessions" + session_dir.mkdir() + source_file = session_dir / "source.jsonl" + write_source_session(source_file, source_cwd) + env = os.environ.copy() + env["PI_OFFLINE"] = "1" + env["PI_CODING_AGENT_DIR"] = str(temp / "pi-agent") + process = subprocess.Popen( + [ + pi_bin, + "--mode", + "rpc", + "--session-dir", + str(session_dir), + "--session", + str(source_file), + "--no-skills", + "--no-extensions", + "-e", + str(EXTENSION), + ], + cwd=source_cwd, + env=env, + text=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + ) + client = RpcClient(process) + try: + commands = client.request({"type": "get_commands"}) + names = {item.get("name") for item in commands.get("data", {}).get("commands", [])} + if not { + "treework-adapter", + "treework-enter", + "treework-return", + }.issubset(names): + fail(f"Pi did not register TreeWork commands: {sorted(str(name) for name in names)}") + + marker = "treework-pi-history-marker" + client.request({"type": "bash", "command": f"printf '{marker}\\n'"}) + initial = client.request({"type": "get_state"})["data"]["sessionFile"] + if not Path(initial).is_file(): + fail(f"Pi did not persist the source session: {initial}") + + client.request( + {"type": "prompt", "message": "/treework-enter adapter --no-resume"}, + timeout=60, + ) + branch_state, branch_header = wait_for_session(client, branch, initial, timeout=60) + if Path(branch_header.get("parentSession", "")).resolve() != Path(initial).resolve(): + fail("Pi branch session did not retain the source session as its parent") + branch_messages = client.request({"type": "get_messages"}) + if marker not in json.dumps(branch_messages, ensure_ascii=False): + fail("Pi branch session did not preserve conversation history") + + adapter = branch_record(control, "adapter") + if adapter["status"] != "in_progress": + fail(f"deferred Pi Enter did not commit the branch transition: {adapter}") + + client.request( + {"type": "prompt", "message": "/treework-return --no-resume"}, + timeout=60, + ) + control_state, control_header = wait_for_session( + client, control, branch_state["sessionFile"] + ) + if Path(control_header.get("parentSession", "")).resolve() != Path( + branch_state["sessionFile"] + ).resolve(): + fail("Pi return session did not retain the branch session as its parent") + returned_messages = client.request({"type": "get_messages"}) + if marker not in json.dumps(returned_messages, ensure_ascii=False): + fail("Pi return session did not preserve conversation history") + finally: + if process.stdin: + process.stdin.close() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.terminate() + process.wait(timeout=10) + if process.returncode not in (0, None): + fail(f"Pi RPC process exited {process.returncode}: {client.stderr}") + + tw_env = os.environ.copy() + tw_env["TREEWORK_PLUGIN_ROOT"] = str(PLUGIN_ROOT) + tw_env["TREEWORK_BUILD_DIR"] = str(temp / "treework-build") + run([str(TW), "pause", "--reason", "prepare cancellation test"], control, tw_env) + events_path = control / ".TreeWork" / "events.jsonl" + events_before = len(events_path.read_text(encoding="utf-8").splitlines()) + cancel_extension = temp / "cancel-switch.ts" + cancel_extension.write_text( + 'export default function (pi) { pi.on("session_before_switch", () => ({ cancel: true })); }\n', + encoding="utf-8", + ) + cancel_sessions = temp / "cancel-sessions" + cancel_sessions.mkdir() + cancel_source = cancel_sessions / "source.jsonl" + write_source_session(cancel_source, control) + cancel_process = subprocess.Popen( + [ + pi_bin, + "--mode", + "rpc", + "--session-dir", + str(cancel_sessions), + "--session", + str(cancel_source), + "--no-skills", + "--no-extensions", + "-e", + str(EXTENSION), + "-e", + str(cancel_extension), + ], + cwd=control, + env=env, + text=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + ) + cancel_client = RpcClient(cancel_process) + try: + marker = "treework-pi-cancel-marker" + cancel_client.request({"type": "bash", "command": f"printf '{marker}\\n'"}) + cancel_client.request( + {"type": "prompt", "message": "/treework-enter adapter --no-resume"}, + timeout=60, + ) + deadline = time.monotonic() + 60 + cancelled_branch: dict[str, Any] = {} + while time.monotonic() < deadline: + cancelled_branch = branch_record(control, "adapter") + event_count = len(events_path.read_text(encoding="utf-8").splitlines()) + if ( + event_count >= events_before + 2 + and cancelled_branch.get("status") == "paused" + and "Pi workspace handoff failed" in cancelled_branch.get("status_reason", "") + ): + break + time.sleep(0.1) + else: + fail( + "cancelled Pi switch did not recover Enter to paused state: " + f"branch={cancelled_branch} events={event_count - events_before}" + ) + state = cancel_client.request({"type": "get_state"})["data"] + if Path(state["sessionFile"]).resolve() != cancel_source.resolve(): + fail("cancelled TreeWork switch unexpectedly replaced the source Pi session") + session_files = sorted(cancel_sessions.glob("*.jsonl")) + if session_files != [cancel_source]: + fail(f"cancelled TreeWork switch left an orphan Pi session: {session_files}") + finally: + if cancel_process.stdin: + cancel_process.stdin.close() + try: + cancel_process.wait(timeout=10) + except subprocess.TimeoutExpired: + cancel_process.terminate() + cancel_process.wait(timeout=10) + if cancel_process.returncode not in (0, None): + fail(f"Pi cancellation RPC process exited {cancel_process.returncode}: {cancel_client.stderr}") + + print( + "ok: Pi conversation round-trips through a TreeWork managed worktree and " + "cancelled handoffs recover to paused" + ) + + +if __name__ == "__main__": + main() From 325aaac200e91c3be20140625028b05b00cdc790 Mon Sep 17 00:00:00 2001 From: TreeWork Development Date: Fri, 7 Aug 2026 15:41:12 +0800 Subject: [PATCH 2/2] fix: resolve Pi package sources across platforms --- scripts/check_pi_adapter.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/check_pi_adapter.py b/scripts/check_pi_adapter.py index 0ebd88a..136c6cb 100755 --- a/scripts/check_pi_adapter.py +++ b/scripts/check_pi_adapter.py @@ -170,6 +170,16 @@ def check_pi_load(pi_bin: str | None) -> None: ok("Pi runtime loads adapter and shared TreeWork skill") +def package_source_matches(source: object, agent_dir: Path) -> bool: + if not isinstance(source, str): + return False + target = REPOSITORY_ROOT.resolve() + return any( + (base / source).resolve() == target + for base in (agent_dir, REPOSITORY_ROOT, Path.cwd()) + ) + + def check_pi_package_install(pi_bin: str | None) -> None: if not pi_bin: return @@ -193,11 +203,7 @@ def check_pi_package_install(pi_bin: str | None) -> None: settings_path = agent_dir / "settings.json" settings = json.loads(settings_path.read_text(encoding="utf-8")) packages = settings.get("packages", []) - recorded = any( - isinstance(source, str) - and (REPOSITORY_ROOT / source).resolve() == REPOSITORY_ROOT.resolve() - for source in packages - ) + recorded = any(package_source_matches(source, agent_dir) for source in packages) if not recorded: fail(f"Pi did not record the TreeWork package source: {packages}") commands = run_pi_rpc_load(pi_bin, [], env) @@ -219,8 +225,7 @@ def check_pi_package_install(pi_bin: str | None) -> None: fail(f"Pi local package rollback failed:\n{remove.stdout}\n{remove.stderr}") settings = json.loads(settings_path.read_text(encoding="utf-8")) still_enabled = any( - isinstance(source, str) - and (REPOSITORY_ROOT / source).resolve() == REPOSITORY_ROOT.resolve() + package_source_matches(source, agent_dir) for source in settings.get("packages", []) ) if still_enabled: