From 40635486be05c60434aed9bb4708d44d8f1ff357 Mon Sep 17 00:00:00 2001 From: sevenjay Date: Mon, 10 Aug 2026 14:57:40 +0800 Subject: [PATCH 1/2] fix: make OpenCode ACP transport persistent Replace the non-portable background stdin assumption with a permanent-FD FIFO controller. Reorganize the installable skill bundle, document the standalone no-Python workflow, align ACP v1 examples, and add lifecycle tests and CI coverage. --- .github/ISSUE_TEMPLATE/bug_report.md | 4 +- .github/ISSUE_TEMPLATE/feature_request.md | 8 +- .github/workflows/ci.yml | 23 +- CONTRIBUTING.md | 74 --- README.md | 195 ++++---- SKILL.md | 239 --------- _meta.json | 4 +- CHANGELOG.md => docs/CHANGELOG.md | 32 +- CODE_OF_CONDUCT.md => docs/CODE_OF_CONDUCT.md | 0 docs/CONTRIBUTING.md | 78 +++ examples/acp_demo.py | 284 ----------- skills/opencode-acp-control/SKILL.md | 378 ++++++++++++++ .../opencode-acp-control/assets/example.json | 14 + .../opencode-acp-control/assets/template.md | 18 + skills/opencode-acp-control/references/api.md | 94 ++++ .../references/guidelines.md | 89 ++++ skills/opencode-acp-control/scripts/helper.sh | 178 +++++++ skills/opencode-acp-control/scripts/run.py | 467 ++++++++++++++++++ tests/fake_opencode.py | 26 + tests/test_acp_demo.py | 158 ------ tests/test_transport.py | 208 ++++++++ 21 files changed, 1695 insertions(+), 876 deletions(-) delete mode 100644 CONTRIBUTING.md delete mode 100644 SKILL.md rename CHANGELOG.md => docs/CHANGELOG.md (74%) rename CODE_OF_CONDUCT.md => docs/CODE_OF_CONDUCT.md (100%) create mode 100644 docs/CONTRIBUTING.md delete mode 100755 examples/acp_demo.py create mode 100644 skills/opencode-acp-control/SKILL.md create mode 100644 skills/opencode-acp-control/assets/example.json create mode 100644 skills/opencode-acp-control/assets/template.md create mode 100644 skills/opencode-acp-control/references/api.md create mode 100644 skills/opencode-acp-control/references/guidelines.md create mode 100755 skills/opencode-acp-control/scripts/helper.sh create mode 100755 skills/opencode-acp-control/scripts/run.py create mode 100755 tests/fake_opencode.py delete mode 100644 tests/test_acp_demo.py create mode 100644 tests/test_transport.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b87e54b..d4d686a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,7 +15,7 @@ A short description of what went wrong. - **opencode --version** (output of `opencode --version`): - **Agent platform** (Hermes Agent, Clawdbot, custom): - **OS** (Linux/macOS/Windows + version): -- **Python version** (only if relevant to `examples/acp_demo.py`): +- **Python version** (only if relevant to the bundled `scripts/run.py`): ## Reproduction @@ -36,4 +36,4 @@ What actually happened — paste logs, error messages, or unexpected output. ## Notes Anything else that might help (related issues, workarounds, agent-specific -quirks). \ No newline at end of file +quirks). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 52a9cea..4609baf 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -12,9 +12,9 @@ What problem are you trying to solve? What works today and what is missing? ## Proposal -A clear description of the change. For `SKILL.md` rewrites, show the new -section structure (e.g. "Quick Reference → Protocol Rules → Workflow → -Failure Modes"). +A clear description of the change. For +`skills/opencode-acp-control/SKILL.md` rewrites, show the new section structure +(e.g. "Quick Reference → Protocol Rules → Workflow → Failure Modes"). ## Alternatives considered @@ -24,4 +24,4 @@ What other approaches did you weigh and why is this one better? - [ ] Changes only documentation (no agent behavior change) - [ ] Changes behavior — list which agents are affected (Hermes, Clawdbot, - etc.) and what the migration path looks like \ No newline at end of file + etc.) and what the migration path looks like diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 939f1e0..6a55e2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,11 @@ jobs: run: | set -e markdownlint --version - markdownlint SKILL.md README.md CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md .github/ISSUE_TEMPLATE/*.md + markdownlint README.md docs/*.md \ + skills/opencode-acp-control/SKILL.md \ + skills/opencode-acp-control/references/*.md \ + skills/opencode-acp-control/assets/*.md \ + .github/ISSUE_TEMPLATE/*.md link-check: name: link check @@ -43,8 +47,8 @@ jobs: run: | set -euo pipefail { - grep -hoE 'https?://[A-Za-z0-9._/?#&+=%-]+' \ - SKILL.md README.md CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md \ + grep -rhoE 'https?://[A-Za-z0-9._/?#&+=%-]+' \ + README.md docs/ skills/opencode-acp-control/ \ | grep -vE '\.(png|jpg|jpeg|gif|svg|ico|pdf)$' \ | sort -u } > urls.txt @@ -75,18 +79,19 @@ jobs: - name: Lint with Ruff run: | set -e - ruff check examples/acp_demo.py + ruff check skills/opencode-acp-control/scripts/run.py ruff check tests/ - - name: Byte-compile the demo script and tests + - name: Check shell and compile Python run: | set -e - python3 -m py_compile examples/acp_demo.py - python3 -m py_compile tests/test_acp_demo.py - echo "OK: Python sources compile" + bash -n skills/opencode-acp-control/scripts/helper.sh + python3 -m py_compile skills/opencode-acp-control/scripts/run.py + python3 -m py_compile tests/fake_opencode.py tests/test_transport.py + echo "OK: transport sources compile" - name: Run unit tests run: | set -e python3 -m pip install --quiet pytest - python3 -m pytest tests/ -v \ No newline at end of file + python3 -m pytest tests/ -v diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 14d050c..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,74 +0,0 @@ -# Contributing to OpenCode ACP Control - -Thanks for your interest in improving this skill. Most changes here are -documentation or examples — the core capability is the `SKILL.md` instruction -set that any ACP-compatible AI agent can load. - -## Quick start - -```bash -git clone https://github.com/berriosb/Opencode-Acp-Control.git -cd Opencode-Acp-Control -``` - -Edits to `SKILL.md` are validated by CI: - -- `markdownlint-cli` (rules in `.markdownlint.json`) -- `lychee` link checker for every URL referenced in docs -- `ruff check` and `python3 -m py_compile` on `examples/acp_demo.py` - -## Where to make changes - -| File | What it controls | -|---|---| -| `SKILL.md` | The instruction set agents load. Keep it tool-agnostic. | -| `examples/acp_demo.py` | Runnable end-to-end demo against a real `opencode acp` process. | -| `README.md` | Public-facing project description and quick start. | -| `CHANGELOG.md` | Release notes, Keep a Changelog format. | -| `_meta.json` | Registry metadata (do not edit `ownerId`/`slug`; only bump `version`/`publishedAt` on release). | - -## Style - -- Markdown follows the existing tone: short sections, tables, code blocks with - JSON-RPC frames, no marketing fluff. -- Tool references in `SKILL.md` use generic names (`terminal`, `process.write`, - `process.poll`, `process.kill`, `web_fetch`, `ask_user`). Map them to your - platform in `README.md` instead. -- Python in `examples/` uses stdlib only (no third-party deps). -- `acp_demo.py` exposes `--dry-run` and `--no-prompt` modes so the workflow - can be exercised without a configured LLM provider. - -## Tests - -```bash -# Markdown lint -markdownlint SKILL.md README.md CHANGELOG.md CODE_OF_CONDUCT.md CONTRIBUTING.md - -# Python syntax + ruff -ruff check examples/acp_demo.py -python3 -m py_compile examples/acp_demo.py - -# Run the demo in dry-run mode (no opencode binary required) -python3 examples/acp_demo.py --dry-run -``` - -## Commit messages - -Conventional commits in English: `feat:`, `fix:`, `docs:`, `chore:`. The -release pipeline uses standard-version to bump the version and update -`CHANGELOG.md`, so commit messages become changelog entries. - -## Reporting issues - -Open an issue at -with: - -- Which agent platform you are loading the skill into (Hermes Agent, - Clawdbot, custom, etc.) -- The exact `opencode --version` you are running -- A minimal reproduction of the unexpected behavior - -## License - -By contributing, you agree that your contributions will be licensed under the -MIT License (see [`LICENSE`](./LICENSE)). \ No newline at end of file diff --git a/README.md b/README.md index 92df73b..a352fdd 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,127 @@ # OpenCode ACP Control -> **A reusable AI agent skill that lets coding agents drive OpenCode CLI sessions over the Agent Client Protocol (ACP).** +A reusable Agent Skill for controlling `opencode acp` over newline-delimited +JSON-RPC without losing the stdio transport when a host runtime closes +background stdin. -[![ClawHub: opencode-acp-control-3](https://img.shields.io/badge/ClawHub-opencode--acp--control--3-FF6B35?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI0NSIgZmlsbD0iIzAwMCIvPjwvc3ZnPg==&logoColor=white)](https://clawhub.ai/berriosb/skills/opencode-acp-control-3) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](./LICENSE) -[![Protocol: ACP / JSON-RPC 2.0](https://img.shields.io/badge/Protocol-ACP%20%2F%20JSON--RPC%202.0-green?style=flat-square)](https://agentclientprotocol.com) -[![OpenCode ≥ v1.1.0](https://img.shields.io/badge/OpenCode-%E2%89%A5%20v1.1.0-black?style=flat-square)](https://opencode.ai) -[![Python 3.8+](https://img.shields.io/badge/Python-3.8%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org) -[![CI: markdownlint + lychee + ruff + pytest](https://img.shields.io/badge/CI-markdownlint%20%2B%20lychee%20%2B%20ruff%20%2B%20pytest-success?style=flat-square)](./.github/workflows/ci.yml) -[![Version: 0.3.0](https://img.shields.io/badge/Version-0.3.0-orange?style=flat-square)](./CHANGELOG.md) -[![Release: v0.3.0](https://img.shields.io/badge/Release-v0.3.0-blue?style=flat-square)](https://github.com/berriosb/Opencode-Acp-Control/releases) +## What changed in 0.4.0 -This repository contains a reusable **skill** (`.md`-based instruction set) -that enables AI coding agents to **start, control, and monitor OpenCode CLI -sessions** over the standardized [Agent Client Protocol (ACP)](https://agentclientprotocol.com), -which speaks **JSON-RPC 2.0** over stdio. +The old recipe launched `opencode acp` with a generic background tool and then +assumed `process.write()` could still reach its stdin. That is not portable. +OpenCode documents ACP as a stdin/stdout protocol, so closing stdin delivers EOF +and terminates the server. -In plain terms: an AI agent can spin up an OpenCode session, send it coding -tasks, stream responses back, resume old conversations by ID, and shut it down -— all through a documented JSON-RPC interface. +The skill now ships a FIFO controller with an explicit ownership model: ---- +```text +short run.py send commands ──► stdin.fifo ──► OpenCode + ▲ + │ FD 3 stays open + controller shell -## What it does - -- Start OpenCode in ACP background mode (`opencode acp`) -- Create, resume, and cancel sessions -- Send prompts and stream responses -- Resume past conversations from saved session IDs -- Handle server-to-client `requestPermission` requests for tool calls -- Detect and trigger OpenCode auto-updates - -See [`SKILL.md`](./SKILL.md) for the full agent-side instructions. +OpenCode ──► stdout.fifo ──► controller FD 4 ──► frames.ndjson +``` ---- +The permanent FD prevents a one-shot writer from becoming the last writer and +accidentally ending the ACP session. The controller also owns the exact child +PID, drains stdout, and performs ordered cleanup. + +## Repository layout + +```text +Opencode-Acp-Control/ +├── README.md +├── LICENSE +├── skills/ +│ └── opencode-acp-control/ +│ ├── SKILL.md +│ ├── scripts/ +│ │ ├── run.py +│ │ └── helper.sh +│ ├── references/ +│ │ ├── api.md +│ │ └── guidelines.md +│ └── assets/ +│ ├── template.md +│ └── example.json +├── docs/ +└── tests/ +``` -## Quick start +The installable skill is the complete +[`skills/opencode-acp-control`](skills/opencode-acp-control/) directory. Keep +the scripts, references, and assets beside `SKILL.md`. -### Install the skill into your agent +## Install -The skill is a single Markdown file. Pick whichever install path matches your -agent platform: +Clone the repository, then copy or link the skill directory into the skill root +used by your agent runtime: ```bash -# Clone the repo git clone https://github.com/berriosb/Opencode-Acp-Control.git -cd Opencode-Acp-Control - -# Hermes Agent — copy into the active profile's skills dir -cp SKILL.md ~/.hermes/profiles//skills/opencode-acp-control.md - -# Or load the whole directory -mkdir -p ~/.hermes/profiles//skills/opencode-acp-control -cp SKILL.md ~/.hermes/profiles//skills/opencode-acp-control/SKILL.md +cp -R Opencode-Acp-Control/skills/opencode-acp-control \ + /path/to/your/agent/skills/ ``` -The agent will pick up the file on its next skills refresh. - -### Try it locally with the demo script +Requirements: -`examples/acp_demo.py` spawns a real `opencode acp` subprocess and walks -through the full JSON-RPC handshake. Stdlib only — no third-party -dependencies. +- Unix-like system with Bash and `mkfifo` +- Python 3.9+ +- OpenCode available as `opencode` on `PATH` -```bash -# Print the JSON-RPC frames the skill produces (no opencode needed) -python3 examples/acp_demo.py --dry-run +## Transport smoke test -# Spawn opencode acp, run initialize + session/new, and exit (no LLM call) -python3 examples/acp_demo.py --no-prompt +Start the controller against a project with the command in background mode: -# Full end-to-end run (requires a configured LLM provider) -python3 examples/acp_demo.py --cwd /path/to/project --prompt "list the files" +```bash +runtime_dir="/tmp/opencode-acp.example.$$" +python3 skills/opencode-acp-control/scripts/run.py start \ + --foreground --cwd "$PWD" --runtime-dir "$runtime_dir" & ``` ---- +Wait for its `READY` line, then send the initialize frame in +[`assets/example.json`](skills/opencode-acp-control/assets/example.json): -## Requirements - -- **OpenCode** (≥ v1.1.0) — installed and available on `$PATH` -- A terminal with background process support -- An ACP-compatible agent (Hermes, Clawdbot, custom, etc.) -- Python 3.8+ only if you want to run the demo script or the unit tests - ---- - -## How it works - -| Step | Action | Description | -|------|--------|-------------| -| 1 | `opencode acp` | Start OpenCode in ACP (background) mode | -| 2 | `initialize` | Initialize JSON-RPC 2.0 connection | -| 3 | `session/new` | Create a new coding session | -| 4 | `session/prompt` | Send prompts, stream responses | -| 5 | `session/cancel` | Cancel mid-response if needed | -| 6 | `session/load` | Resume a previous session by ID | -| 7 | `requestPermission` | Approve or deny tool-call requests | +```bash +python3 skills/opencode-acp-control/scripts/run.py send \ + --runtime-dir "$runtime_dir" \ + --file skills/opencode-acp-control/assets/example.json -The transport is **newline-delimited JSON-RPC 2.0** on stdio (one JSON -object per line, frames terminated by `\n`). OpenCode does **not** use the -LSP `Content-Length` framing. +python3 skills/opencode-acp-control/scripts/run.py read \ + --runtime-dir "$runtime_dir" --from-line 0 --wait 2 ---- +python3 skills/opencode-acp-control/scripts/run.py stop \ + --runtime-dir "$runtime_dir" +``` -## Tool mapping for AI agents +## Controller commands -This skill uses generic tool names. Map them to your platform: +| Command | Purpose | +|---|---| +| `start --foreground --cwd DIR` | Run a controller under a background-process tool | +| `start --cwd DIR` | Detach only when the runtime preserves descendants | +| `send --runtime-dir DIR --frame JSON` | Write one validated JSON-RPC frame | +| `read --runtime-dir DIR --from-line N` | Poll complete frames using a cursor | +| `status --runtime-dir DIR` | Check controller and child liveness | +| `stop --runtime-dir DIR` | Close the permanent FD and clean the runtime | +| `clean --runtime-dir DIR` | Remove an already stopped retained runtime | -| Generic Name | Hermes Agent | Clawdbot | -|---|---|---| -| Run command (background) | `terminal()` | `bash()` | -| Write to process | `process.write()` | `process.write()` | -| Read process output | `process.poll()` | `process.poll()` | -| Kill process | `process.kill()` | `process.kill()` | -| Web fetch | `web_extract()` | `webfetch()` | -| User prompt | `clarify()` | `askUser()` | +See [`SKILL.md`](skills/opencode-acp-control/SKILL.md) for the agent workflow +and [`guidelines.md`](skills/opencode-acp-control/references/guidelines.md) for +FD ownership and recovery invariants. ---- +## Development -## Files +```bash +bash -n skills/opencode-acp-control/scripts/helper.sh +python3 -m py_compile skills/opencode-acp-control/scripts/run.py +python3 -m pytest tests/ -v +``` -- [`SKILL.md`](./SKILL.md) — The skill definition (load this into your agent) -- [`examples/acp_demo.py`](./examples/acp_demo.py) — Runnable Python script - that demonstrates the full ACP workflow against a live `opencode acp` process -- [`tests/`](./tests) — Pytest suite covering the JSON-RPC framing and the - demo CLI's `--dry-run` and `--no-prompt` paths -- [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) — CI: - markdownlint + URL link check + ruff + pytest -- [`CHANGELOG.md`](./CHANGELOG.md) — Release notes (Keep a Changelog format) -- [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md) — Contributor Covenant v2.1 -- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — How to file issues and PRs +Project documents live under [`docs/`](docs/), including the +[`CHANGELOG`](docs/CHANGELOG.md) and +[`contribution guide`](docs/CONTRIBUTING.md). ## License -MIT — see [`LICENSE`](./LICENSE). \ No newline at end of file +MIT — see [`LICENSE`](LICENSE). diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index 202f495..0000000 --- a/SKILL.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -name: opencode-acp-control -description: Use when an AI agent needs to start, drive, or monitor an OpenCode CLI session programmatically over the Agent Client Protocol (ACP). Triggers include requests to "spawn OpenCode", "control OpenCode from another agent", "automate OpenCode over JSON-RPC", "resume an OpenCode session", or any task that requires the agent to act as an ACP client rather than an interactive user. Provides the JSON-RPC 2.0 framing, session lifecycle, polling strategy, permission-request handling, and update detection needed to wrap OpenCode from inside another AI agent. -metadata: - version: "0.3.0" - author: "Bastian Berrios " - license: "MIT" - github_url: "https://github.com/berriosb/Opencode-Acp-Control" ---- - -# OpenCode ACP Skill - -Drive an OpenCode CLI session over the Agent Client Protocol (ACP). - -## When to use this skill - -Use it when the calling agent must: - -- Start an OpenCode process and talk to it programmatically (not as a human at - a terminal). -- Drive multi-turn coding sessions, including prompt → stream → cancel loops. -- Resume a previous OpenCode session by ID. -- Detect and trigger OpenCode auto-updates. - -Do **not** use it for direct file editing, one-off shell commands, or any -task that does not require the Agent Client Protocol. - -## Quick Reference - -| Action | Generic tool call | -|---|---| -| Start OpenCode | `terminal(command: "opencode acp --cwd /path/to/project", background: true)` | -| Send JSON-RPC frame | `process.write(processId, "\n")` | -| Read available output | `process.poll(processId)` (repeat every ~2s) | -| Stop OpenCode | `process.kill(processId)` | -| List past sessions | `terminal(command: "opencode session list", workdir: "")` | -| Get current version | `terminal(command: "opencode --version")` | -| Prompt the user | `ask_user(question, options)` | - -The calling agent must map these generic names to its own platform (Hermes, -Clawdbot, etc.). See `README.md` for the mapping table. - -## Protocol Rules - -- Wire format: **JSON-RPC 2.0**, **newline-delimited** (one JSON object per - line, each frame terminated by `\n`). Not LSP `Content-Length`. -- Direction: requests are agent → OpenCode (stdin). Responses and server - notifications arrive on stdout. -- IDs: every request carries an integer `id`; the calling agent increments - monotonically starting at 0. Notifications have no `id` and never produce a - response. -- Sessions are opaque: the `sessionId` returned by `session/new` is the only - reference; treat it as a string. -- Capabilities: declare `fs.readTextFile`, `fs.writeTextFile`, and `terminal` - in the `initialize` handshake. - -## Standard Workflow - -### 1. Start - -``` -terminal( - command: "opencode acp --cwd /path/to/project", - background: true, - workdir: "/path/to/project" -) -``` - -Save the returned `processId`. All subsequent frames go through it. - -### 2. Initialize - -```json -{"jsonrpc":"2.0","id":0,"method":"initialize","params":{ - "protocolVersion":1, - "clientCapabilities":{ - "fs":{"readTextFile":true,"writeTextFile":true}, - "terminal":true - }, - "clientInfo":{ - "name":"opencode-acp-control", - "title":"OpenCode ACP Control", - "version":"0.3.0" - } -}} -``` - -Expect `result.protocolVersion: 1`. - -### 3. Create session - -```json -{"jsonrpc":"2.0","id":1,"method":"session/new","params":{ - "cwd":"/path/to/project", - "mcpServers":[] -}} -``` - -Save `result.sessionId` (e.g. `"sess_abc123"`). - -### 4. Send prompt - -```json -{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{ - "sessionId":"sess_abc123", - "prompt":[{"type":"text","text":"List the TypeScript files in this repo."}] -}} -``` - -### 5. Stream the response - -Poll stdout every ~2s until a response arrives whose `id` matches your request -and whose `result.stopReason` is set. While polling you will also receive -notifications: - -```json -{"jsonrpc":"2.0","method":"session/update","params":{...}} -``` - -Collect them in order — they make up the agent's streamed output. - -### 6. Cancel (when needed) - -```json -{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess_abc123"}} -``` - -No response is sent for a cancel — it is a notification. - -### 7. Handle permission requests - -OpenCode asks for confirmation before running shell commands or editing files -by sending a server-to-client request: - -```json -{"jsonrpc":"2.0","id":12,"method":"requestPermission","params":{ - "sessionId":"sess_abc123", - "toolCall":{"toolCallId":"call_1","status":"pending", - "title":"bash","rawInput":{"command":"npm install"},"kind":"bash"} -}} -``` - -Prompt the user, then respond with the matching `id`: - -```json -{"jsonrpc":"2.0","id":12,"result":{"reply":"once"}} // allow once -{"jsonrpc":"2.0","id":12,"result":{"reply":"always"}} // allow for the session -{"jsonrpc":"2.0","id":12,"result":{"reply":"reject"}} // deny -``` - -## State to Track - -For each OpenCode instance the calling agent holds: - -| Field | Source | -|---|---| -| `processId` | Returned by the `terminal(background:true)` call | -| `sessionId` | Returned by `session/new` (OpenCode-internal) | -| `nextId` | Integer counter for the next request, starting at 0 | -| `stopReason` | Last terminal reason observed (`end_turn`, `cancelled`, `max_tokens`) | - -## Polling and Timeout Strategy - -- Interval: **2 seconds** between `process.poll` calls. -- Maximum wait per prompt: **5 minutes** (150 polls). Treat as timeout beyond. -- An empty poll response means the agent is still thinking — keep polling. -- A malformed line on stdout is logged and skipped; do not abort on parse - errors alone. - -## Resume Session - -1. `terminal("opencode session list", workdir: "")` returns a table of - `{id, updated, messages}`. -2. `ask_user` to pick one. -3. `terminal("opencode acp --cwd ", background:true)` to restart. -4. `initialize` (id=0). -5. `session/load` with the chosen id, plus `cwd` and `mcpServers`: - - ```json - {"jsonrpc":"2.0","id":1,"method":"session/load","params":{ - "sessionId":"sess_abc123","cwd":"/path/to/project","mcpServers":[] - }} - ``` - -OpenCode streams the full conversation history back through `session/update` -notifications. - -## Failure Modes - -| Symptom | Likely cause | Action | -|---|---|---| -| Empty polls for >5 min | Long agent thinking, model stall, or network drop | Cancel + restart | -| `parse error` on stdout | Garbled binary output or partial frame | Skip the line, continue | -| Process exits unexpectedly | OpenCode crashed | Inspect stderr, restart | -| `initialize` rejects `protocolVersion` | OpenCode < v1.1.0 or client drift | Upgrade OpenCode, align `clientInfo.version` | -| `requestPermission` keeps arriving | Session is in a tool-call loop | Cancel, narrow the prompt | -| `session/load` 404s | Stale or deleted session id | Fall back to `session/new` | - -## Update Procedure - -OpenCode auto-updates on restart. To check and trigger an update: - -1. `terminal("opencode --version")` → current version. -2. `web_fetch("https://github.com/sst/opencode/releases/latest")` → latest tag - in the redirect URL. -3. Compare versions. If newer: - - `process(action:"list")` to find every running `opencode acp` process. - - `process.kill(processId)` for each. - - Wait ~2 seconds. - - `terminal("opencode acp", background:true)` to restart and trigger the - auto-download. -4. Verify with `opencode --version` again. If still old, fall back to a manual - install (review the installer script before piping `curl | bash`): - - ``` - curl -fsSL https://opencode.ai/install | bash - ``` - -## Implementation Notes - -- `cwd` must be absolute; normalize before sending. -- Serialize JSON deterministically (`sort_keys=True` if possible) to make log - diffs stable. -- Treat stderr separately from stdout if your platform exposes it — ACP - frames never appear on stderr. -- When the host agent runs in a sandbox, `cwd` must be inside the sandbox; - the ACP server inherits the calling agent's filesystem access. -- Session ids are opaque strings; do not parse them. -- The first `initialize` after a process start must complete before any - other request — JSON-RPC servers reject out-of-order calls. - -## See also - -- `examples/acp_demo.py` — runnable end-to-end demo with `--dry-run` and - `--no-prompt` modes. -- `README.md` — quick start and tool-platform mapping. -- `CHANGELOG.md` — release notes. -- ACP spec: -- OpenCode: \ No newline at end of file diff --git a/_meta.json b/_meta.json index db090a7..f74d110 100644 --- a/_meta.json +++ b/_meta.json @@ -1,6 +1,6 @@ { "ownerId": "kn74akfj24s29c981jn5zcej0580v987", "slug": "opencode-acp-control-3", - "version": "0.3.0", + "version": "0.4.0", "publishedAt": 1770640762925 -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/docs/CHANGELOG.md similarity index 74% rename from CHANGELOG.md rename to docs/CHANGELOG.md index 2f9a49e..a89cd42 100644 --- a/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,34 @@ 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.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-08-10 + +### Fixed + +- Replaced the non-portable primary startup recipe that assumed a generic + background process retained writable stdin. +- Added a Bash controller that permanently owns the stdin FIFO writer FD and + drains a stdout FIFO for the entire OpenCode ACP process lifetime. +- Corrected ACP v1 permission handling to use + `session/request_permission`, offered `optionId` values, and the current + `outcome` response shape. +- The initialization example now advertises only capabilities the calling + client actually implements. + +### Added + +- Installable bundle under `skills/opencode-acp-control/`. +- `scripts/run.py` transport CLI with start, send, read, status, stop, and + stale-runtime cleanup commands. +- Transport ownership and ACP v1 reference documents, plus reusable assets. +- End-to-end tests with a fake nd-JSON server. The tests prove that independent + one-shot FIFO sends do not deliver EOF while the permanent FD remains open. + +### Changed + +- Moved project-level community and release documents under `docs/`. +- Replaced the old `examples/acp_demo.py` with the bundled runtime scripts. + ## [0.3.0] - 2026-08-01 ### Changed @@ -26,8 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`CODE_OF_CONDUCT.md`** — Contributor Covenant v2.1. -- **`CONTRIBUTING.md`** — How to file issues and PRs, local validation +- **`docs/CODE_OF_CONDUCT.md`** — Contributor Covenant v2.1. +- **`docs/CONTRIBUTING.md`** — How to file issues and PRs, local validation commands, style rules. - **Issue templates** under `.github/ISSUE_TEMPLATE/` for bug reports and feature requests. diff --git a/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to docs/CODE_OF_CONDUCT.md diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..fb1a1c9 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to OpenCode ACP Control + +Thanks for your interest in improving this skill. Most changes here are +documentation and bundled transport scripts. The core capability is the +`skills/opencode-acp-control/` directory that an ACP-compatible agent loads. + +## Quick start + +```bash +git clone https://github.com/berriosb/Opencode-Acp-Control.git +cd Opencode-Acp-Control +``` + +Edits to the skill bundle are validated by CI: + +- `markdownlint-cli` (rules in `.markdownlint.json`) +- `lychee` link checker for every URL referenced in docs +- `bash -n` on the FIFO controller +- `ruff check`, byte compilation, and pytest on the Python code + +## Where to make changes + +| File | What it controls | +|---|---| +| `skills/opencode-acp-control/SKILL.md` | The instruction set agents load. | +| `skills/opencode-acp-control/scripts/helper.sh` | Permanent-FD FIFO owner and cleanup controller. | +| `skills/opencode-acp-control/scripts/run.py` | User-facing transport command line. | +| `skills/opencode-acp-control/references/` | ACP and lifecycle detail loaded on demand. | +| `README.md` | Public-facing project description and quick start. | +| `docs/CHANGELOG.md` | Release notes, Keep a Changelog format. | +| `_meta.json` | Registry metadata (do not edit `ownerId`/`slug`; only bump `version`/`publishedAt` on release). | + +## Style + +- Markdown follows the existing tone: short sections, tables, code blocks with + JSON-RPC frames, no marketing fluff. +- Keep the permanent-writer ownership invariant explicit: OpenCode must never + inherit FD 3, and one-shot senders must never become the final writer. +- Bundled Python uses the standard library only (no third-party runtime deps). +- Protocol examples must match the current stable ACP v1 documentation. + +## Tests + +```bash +# Markdown lint +markdownlint README.md docs/*.md skills/opencode-acp-control/SKILL.md \ + skills/opencode-acp-control/references/*.md \ + skills/opencode-acp-control/assets/*.md + +# Python syntax + ruff +ruff check skills/opencode-acp-control/scripts/run.py tests/ +python3 -m py_compile skills/opencode-acp-control/scripts/run.py +bash -n skills/opencode-acp-control/scripts/helper.sh + +# Run the fake-server transport suite (no real OpenCode/provider required) +python3 -m pytest tests/ -v +``` + +## Commit messages + +Conventional commits in English: `feat:`, `fix:`, `docs:`, `chore:`. The +release pipeline uses standard-version to bump the version and update +`CHANGELOG.md`, so commit messages become changelog entries. + +## Reporting issues + +Open an issue at +with: + +- Which agent platform you are loading the skill into (Hermes Agent, + Clawdbot, custom, etc.) +- The exact `opencode --version` you are running +- A minimal reproduction of the unexpected behavior + +## License + +By contributing, you agree that your contributions will be licensed under the +MIT License (see [`LICENSE`](../LICENSE)). diff --git a/examples/acp_demo.py b/examples/acp_demo.py deleted file mode 100755 index 36c14e8..0000000 --- a/examples/acp_demo.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python3 -""" -examples/acp_demo.py — End-to-end demo of the OpenCode ACP workflow. - -What this script does: - 1. Spawns `opencode acp` as a subprocess (stdio JSON-RPC 2.0 transport). - 2. Sends an `initialize` request using this skill's own clientInfo. - 3. Sends a `session/new` request and captures the returned sessionId. - 4. Sends a single `session/prompt` request with a tiny code task. - 5. Streams all session/update notifications until the prompt completes. - 6. Gracefully shuts the subprocess down. - -Usage: - python3 examples/acp_demo.py # full run, real OpenCode - python3 examples/acp_demo.py --dry-run # print the JSON-RPC frames without spawning - python3 examples/acp_demo.py --no-prompt # stop after session/new (no LLM call) - python3 examples/acp_demo.py --cwd /path/to/proj # opencode starts in that directory - python3 examples/acp_demo.py --prompt "list files" # custom prompt - -Notes: - The default flow sends a `session/prompt`, which requires a configured LLM - provider (`opencode providers list` should show at least one credential). - If no provider is configured, run with `--no-prompt` to verify the ACP - handshake (`initialize` + `session/new`) without invoking the model. - -Requirements: - - opencode >= 1.1.0 available on $PATH - - Python 3.8+ (uses subprocess, json, argparse from stdlib only) - - No third-party dependencies - -Exit codes: - 0 success - 1 opencode binary not found - 2 initialize failed - 3 session/new failed - 4 session/prompt failed or returned an error - 5 unexpected subprocess exit -""" -from __future__ import annotations - -import argparse -import json -import os -import select -import shutil -import subprocess -import sys -import time -from pathlib import Path -from typing import Any - -PROTOCOL_VERSION = 1 -CLIENT_INFO = { - "name": "opencode-acp-control", - "title": "OpenCode ACP Control", - "version": "0.3.0", -} -CLIENT_CAPABILITIES = { - "fs": {"readTextFile": True, "writeTextFile": True}, - "terminal": True, -} - - -def frame(request: dict[str, Any]) -> bytes: - """Serialize a JSON-RPC request as newline-delimited JSON. - - OpenCode's ACP transport uses nd-JSON (one JSON object per line) on stdio, - not the LSP `Content-Length` framing. - """ - return (json.dumps(request) + "\n").encode("utf-8") - - -def read_frame(stream, timeout: float = 0.0) -> dict[str, Any] | None: - """Read one newline-delimited JSON-RPC message from stdin. - - If timeout > 0, waits for readability before reading. - Returns None on EOF. - """ - if timeout > 0: - ready, _, _ = select.select([stream], [], [], timeout) - if not ready: - raise TimeoutError("timed out waiting for data from opencode") - line = stream.readline() - if not line: - return None - line = line.strip() - if not line: - return None - try: - return json.loads(line.decode("utf-8")) - except json.JSONDecodeError as exc: - print(f"[demo] malformed JSON from opencode: {exc} line={line!r}", file=sys.stderr) - return None - - -def send_request(proc, method: str, params: Any, msg_id: int) -> int: - """Send a JSON-RPC request framed for the ACP transport. Returns the id used.""" - payload = {"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params} - proc.stdin.write(frame(payload)) - proc.stdin.flush() - return msg_id - - -def wait_for_id(stream, target_id: int, *, timeout: float = 30.0) -> dict[str, Any]: - """Drain notifications until we see the response for `target_id` or time out.""" - deadline = time.time() + timeout - while True: - remaining = deadline - time.time() - if remaining <= 0: - raise TimeoutError(f"timed out waiting for response id={target_id}") - msg = read_frame(stream, timeout=max(0.1, remaining)) - if msg is None: - raise EOFError("opencode closed stdio before responding") - if "id" in msg and msg["id"] == target_id: - return msg - # Notifications (no "id" key) are ignored here; session/update is handled - # separately by stream_session_updates(). - if "method" in msg and msg["method"] != "session/update": - print(f"[demo] notification: {msg['method']}") - - -def stream_session_updates(stream, proc, until_id: int, *, timeout: float = 60.0) -> dict[str, Any] | None: - """Print session/update notifications and handles permission requests until the prompt response arrives. - - Returns the matching response frame, or None. - """ - deadline = time.time() + timeout - text_chunks: list[str] = [] - while True: - remaining = deadline - time.time() - if remaining <= 0: - print("[demo] warning: session/prompt timed out", file=sys.stderr) - return None - try: - msg = read_frame(stream, timeout=max(0.1, remaining)) - except TimeoutError: - continue - if msg is None: - return None - if msg.get("id") == until_id: - return msg - - # Handle Server-to-Client Requests (e.g., requestPermission) - if "method" in msg and "id" in msg: - if msg["method"] == "requestPermission": - params = msg.get("params", {}) - tool_call = params.get("toolCall", {}) - title = tool_call.get("title", "unknown") - print(f"[demo] server request: requestPermission for '{title}' (id={msg['id']})") - - # Auto-approve permission request to prevent deadlocks - reply = { - "jsonrpc": "2.0", - "id": msg["id"], - "result": {"reply": "once"} - } - proc.stdin.write(frame(reply)) - proc.stdin.flush() - print(f"[demo] auto-approved permission request id={msg['id']}") - continue - - if msg.get("method") == "session/update": - params = msg.get("params", {}) or {} - update = params.get("update", {}) or {} - kind = update.get("sessionUpdate") or update.get("type") or "update" - content = update.get("content") or update.get("message") or "" - if isinstance(content, dict): - content = content.get("text", "") - if kind == "agent_message_chunk" and content: - text_chunks.append(str(content)) - print(f"[session] {kind}: {str(content)[:160]}") - continue - if "method" in msg: - print(f"[demo] notification: {msg['method']}") - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--cwd", default=os.getcwd(), help="working directory for opencode (default: current dir)") - parser.add_argument("--prompt", default="List the files in the current directory and print their names.", - help="prompt text to send via session/prompt") - parser.add_argument("--dry-run", action="store_true", help="print JSON-RPC frames without spawning opencode") - parser.add_argument("--timeout", type=float, default=60.0, help="seconds to wait for the prompt to finish") - parser.add_argument("--no-prompt", action="store_true", - help="stop after session/new (skips the LLM call, useful when no provider is configured)") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - - if args.dry_run: - print("=== initialize ===") - print(json.dumps({"jsonrpc": "2.0", "id": 0, "method": "initialize", - "params": {"protocolVersion": PROTOCOL_VERSION, - "clientCapabilities": CLIENT_CAPABILITIES, - "clientInfo": CLIENT_INFO}}, indent=2)) - print("=== session/new ===") - print(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {"cwd": args.cwd, "mcpServers": []}}, indent=2)) - print("=== session/prompt ===") - print(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "session/prompt", - "params": {"sessionId": "", "prompt": [{"type": "text", "text": args.prompt}]}}, indent=2)) - return 0 - - opencode_bin = shutil.which("opencode") - if opencode_bin is None: - print("[demo] opencode binary not found on $PATH", file=sys.stderr) - print("[demo] install it from https://opencode.ai or pass --dry-run", file=sys.stderr) - return 1 - - cwd = Path(args.cwd).expanduser().resolve() - if not cwd.is_dir(): - print(f"[demo] --cwd is not a directory: {cwd}", file=sys.stderr) - return 1 - - print(f"[demo] spawning: {opencode_bin} acp (cwd={cwd})") - proc = subprocess.Popen( - [opencode_bin, "acp"], - cwd=str(cwd), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=sys.stderr, # Redirect to sys.stderr to avoid deadlock - text=False, - bufsize=0, - ) - assert proc.stdin and proc.stdout - - try: - # 1. initialize - send_request(proc, "initialize", - {"protocolVersion": PROTOCOL_VERSION, - "clientCapabilities": CLIENT_CAPABILITIES, - "clientInfo": CLIENT_INFO}, msg_id=0) - init_resp = wait_for_id(proc.stdout, 0, timeout=15.0) - if "error" in init_resp: - print(f"[demo] initialize error: {init_resp['error']}", file=sys.stderr) - return 2 - server_info = (init_resp.get("result") or {}).get("serverInfo") or {} - print(f"[demo] initialized: serverInfo={server_info}") - - # 2. session/new - send_request(proc, "session/new", {"cwd": str(cwd), "mcpServers": []}, msg_id=1) - new_resp = wait_for_id(proc.stdout, 1, timeout=15.0) - if "error" in new_resp: - print(f"[demo] session/new error: {new_resp['error']}", file=sys.stderr) - return 3 - session_id = (new_resp.get("result") or {}).get("sessionId") - if not session_id: - print("[demo] session/new response missing sessionId", file=sys.stderr) - return 3 - print(f"[demo] sessionId={session_id}") - - if args.no_prompt: - print("[demo] --no-prompt set, skipping session/prompt (no LLM call required)") - return 0 - - # 3. session/prompt - send_request(proc, "session/prompt", - {"sessionId": session_id, - "prompt": [{"type": "text", "text": args.prompt}]}, msg_id=2) - prompt_resp = stream_session_updates(proc.stdout, proc, until_id=2, timeout=args.timeout) - if prompt_resp is None: - print("[demo] session/prompt response not received or timed out", file=sys.stderr) - return 4 - if "error" in prompt_resp: - print(f"[demo] session/prompt error: {prompt_resp['error']}", file=sys.stderr) - return 4 - result = prompt_resp.get("result") or {} - print(f"[demo] session/prompt completed: stopReason={result.get('stopReason')}") - return 0 - except (TimeoutError, EOFError) as exc: - print(f"[demo] protocol error: {exc}", file=sys.stderr) - return 5 - finally: - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/opencode-acp-control/SKILL.md b/skills/opencode-acp-control/SKILL.md new file mode 100644 index 0000000..3587456 --- /dev/null +++ b/skills/opencode-acp-control/SKILL.md @@ -0,0 +1,378 @@ +--- +name: opencode-acp-control +description: Start, drive, monitor, resume, and stop OpenCode CLI sessions over ACP. Use when an agent must control `opencode acp` programmatically through JSON-RPC, especially in runtimes whose background-process tools close stdin or that have no Python. Includes a persistent-FD FIFO controller and a standalone Bash workflow. +metadata: + version: "0.4.0" + license: "MIT" +--- + +# OpenCode ACP Control + +Control OpenCode through ACP v1 over newline-delimited JSON-RPC. + +## Critical transport rule + +OpenCode ACP uses stdin/stdout as the transport. Its stdin writer **must remain +open for the entire ACP process lifetime**. + +Never use this as the default startup recipe: + +```text +terminal(command: "opencode acp --cwd ...", background: true) +process.write(processId, frame) +``` + +Some runtimes close a background process's stdin immediately. OpenCode then +receives EOF and exits before `initialize`. A one-shot `echo > fifo` has the +same defect when it is the FIFO's final writer. + +The recommended recipe is the bundled `scripts/run.py`. It starts +`scripts/helper.sh`, whose controller owns a permanent FIFO write descriptor. +Each `send` command may open and close its own writer without causing EOF. If +Python is unavailable, `helper.sh` can run independently as the transport +controller; the calling agent must then perform sending, polling, locking, and +cleanup itself as documented below. + +Only use a host's native background-process API when its documentation or a +preflight proves that writable stdin remains attached after the launch call +returns. Native support is an optimization, not a portable assumption. + +## Requirements + +- Unix-like runtime with Bash and `mkfifo` +- POSIX `awk` for line-cursor polling in the no-Python recipe +- Python 3.9 or newer for the recommended `run.py` interface; optional when + using `helper.sh` directly +- `opencode` on `PATH` +- An absolute project working directory + +Resolve `` to the directory containing this `SKILL.md`. Do not copy +`run.py` away from `helper.sh`; it resolves the helper relative to itself. + +## Primary startup recipe + +### 1. Start the transport + +Launch this command with the host's background-process tool: + +```bash +python3 /scripts/run.py start --foreground --cwd /absolute/project +``` + +The host may close the command's stdin; this controller does not read it. Poll +the background output until the helper prints: + +```text +READY/tmp/opencode-acp.x100101 +``` + +Save the path after `READY` as `runtimeDir`; it is the transport handle. The +last two values are the controller and OpenCode PIDs, not the OpenCode session +ID. Then confirm both are alive with `run.py status`. + +If a host explicitly supports detached descendants and no background-process +handle is available, omit `--foreground`: + +```bash +python3 /scripts/run.py start --cwd /absolute/project +``` + +This returns a JSON status object after detaching. Do not use detached mode in a +runtime that automatically reaps descendants when the launch command exits. + +### 2. Initialize and verify the transport + +Only advertise client capabilities the calling agent actually implements. This +minimal handshake advertises none: + +```bash +python3 /scripts/run.py send \ + --runtime-dir \ + --frame '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"opencode-acp-control","title":"OpenCode ACP Control","version":"0.4.0"}}}' +``` + +Poll from line zero: + +```bash +python3 /scripts/run.py read \ + --runtime-dir --from-line 0 --wait 2 +``` + +Save the returned `nextLine` as the next cursor. Do not send `session/new` +until a response with `id: 0` confirms `result.protocolVersion: 1`. + +If the controller or OpenCode exits before this response, classify it as a +transport/startup failure. Inspect `/stderr.log` and +`/controller.log`; do not mislabel it as an ACP method error. + +### 3. Create a session + +Increment the request ID and send: + +```json +{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/absolute/project","mcpServers":[]}} +``` + +Poll using the saved line cursor. Save `result.sessionId` exactly as returned. + +### 4. Send a prompt + +```json +{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess_abc123","prompt":[{"type":"text","text":"List the files in this project."}]}} +``` + +Poll repeatedly, always replacing the cursor with the newest `nextLine`. +Preserve `session/update` notifications in order. A prompt finishes only when +the response whose `id` matches the prompt request contains +`result.stopReason` or an `error`. + +### 5. Stop and clean up + +Always stop the transport when finished: + +```bash +python3 /scripts/run.py stop --runtime-dir +``` + +This signals the exact controller, closes permanent FD 3, lets OpenCode exit on +stdin EOF, escalates to `TERM` only if needed, removes the FIFO nodes, and then +removes the owned runtime directory. Use `--keep-runtime` only when logs are +needed; later run `clean` on the stopped runtime. + +## No-Python environment: use helper.sh directly + +`scripts/helper.sh` can operate without `run.py` or Python. It is a complete +transport lifecycle controller, but it is not a complete ACP client. + +Used by itself, the helper still: + +- creates the private runtime directory and stdin/stdout FIFO pair; +- starts the exact `opencode acp --cwd ...` child; +- permanently holds FD 3 as the stdin writer; +- continuously drains stdout through FD 4 into `frames.ndjson`; +- records controller and OpenCode PIDs; +- handles `INT`, `TERM`, `HUP`, child waiting, graceful EOF, and FIFO cleanup. + +It does **not** provide `run.py`'s JSON validation, cross-process writer lock, +structured line-cursor result, PID ownership verification, stale-runtime repair, +or automatic removal of retained logs. The calling agent owns those duties. + +### 1. Create and save a private runtime directory + +Run: + +```bash +mktemp -d "${TMPDIR:-/tmp}/opencode-acp.XXXXXX" +``` + +Save the exact printed path as `runtimeDir`. Substitute that literal path in +later commands; do not assume shell variables survive across agent tool calls. +The helper also forces the directory mode to `0700`. + +### 2. Start helper.sh with the background-process tool + +```bash +bash /scripts/helper.sh start \ + --cwd /absolute/project \ + --runtime-dir /tmp/opencode-acp.ABC123 +``` + +The command intentionally remains running. The host may close its stdin because +the helper never reads controller commands from stdin. Poll the background +output until it reports: + +```text +READY/tmp/opencode-acp.ABC123100101 +``` + +Save the controller PID and OpenCode PID from this current `READY` line. At this +point the runtime contains: + +```text +stdin.fifo # write one nd-JSON request or response frame here +stdout.fifo # owned and drained by helper FD 4; do not read directly +frames.ndjson # append-only OpenCode stdout queue; poll this file +stderr.log # OpenCode diagnostics +state # starting, ready, or stopped: +controller.pid +opencode.pid +``` + +### 3. Send initialize through the stdin FIFO + +Use `printf`, not `echo`, and terminate every frame with exactly one newline: + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"opencode-acp-control-shell","title":"OpenCode ACP Control Shell","version":"0.4.0"}}}' \ + >/tmp/opencode-acp.ABC123/stdin.fifo +``` + +This command opens and closes a one-shot FIFO writer. It does not end OpenCode, +because the controller still owns permanent FD 3. + +Without `run.py` locking, send exactly one frame at a time and never run two +writers concurrently. Concurrent writers, especially with frames larger than +`PIPE_BUF`, can interleave and corrupt the nd-JSON stream. The calling agent +must also ensure that each frame is a valid, single-line JSON-RPC 2.0 object +before writing it. + +### 4. Poll complete stdout frames with a line cursor + +Use an integer cursor beginning at `0`. This example prints every line after +cursor zero and reports the last line it observed: + +```bash +awk -v from=0 ' + NR > from { print } + { last = NR } + END { print "NEXT_LINE=" (last + 0) > "/dev/stderr" } +' /tmp/opencode-acp.ABC123/frames.ndjson +``` + +Save the reported `NEXT_LINE` value. On the next poll replace `from=0` with +that value. The `awk` invocation derives frames and the next cursor from the +same file read, avoiding a race between separate `sed` and `wc` commands. + +Parse each printed line as an independent JSON-RPC object. Complete the normal +`initialize` → `session/new` → `session/prompt` workflow using the frames in +[references/api.md](references/api.md). Continue responding to server-to-client +requests such as `session/request_permission` while waiting for the prompt's +matching terminal response. + +### 5. Check status and diagnostics + +```bash +cat /tmp/opencode-acp.ABC123/state +kill -0 "$(cat /tmp/opencode-acp.ABC123/controller.pid)" +kill -0 "$(cat /tmp/opencode-acp.ABC123/opencode.pid)" +``` + +Only use these PIDs with the runtime created during the current launch. Unlike +`run.py status`, these shell checks do not protect against stale PID reuse. +Inspect `stderr.log` if either process exits before the initialize response. + +### 6. Stop the standalone controller + +Compare `controller.pid` with the controller PID saved from the current +`READY` line. If they match, request an ordered shutdown: + +```bash +kill -TERM "$(cat /tmp/opencode-acp.ABC123/controller.pid)" +``` + +Poll the background output for `STOPPED` or read `state` until it starts with +`stopped:`. The helper closes FD 3, lets OpenCode exit on EOF, sends `TERM` if +the child does not exit during the grace period, closes FD 4, and unlinks both +FIFO nodes. It intentionally retains the regular logs and state for diagnosis. + +After confirming a normal stopped state, remove only the exact owned runtime: + +```bash +runtime_dir=/tmp/opencode-acp.ABC123 +owner=$(cat "$runtime_dir/.owner") +state=$(cat "$runtime_dir/state") + +if [[ "$owner" == "opencode-acp-control-runtime-v1" && "$state" == stopped:* ]]; then + rm -r -- "$runtime_dir" +else + printf 'Refusing to remove unverified or active runtime: %s\n' "$runtime_dir" >&2 +fi +``` + +`SIGKILL` and host crashes cannot run the helper trap. The kernel still closes +all FDs, so OpenCode receives EOF, but FIFO nodes may remain and `state` may +still say `ready`. Without Python's ownership checks, do not automatically +remove or signal such a stale runtime; inspect the recorded PIDs and process +command lines first. + +## State to track + +| Field | Meaning | +|---|---| +| `runtimeDir` | Transport/controller handle created by the startup recipe | +| `controllerPid` | Shell process that owns FIFO FDs and cleanup | +| `opencodePid` | Exact OpenCode child process | +| `sessionId` | Opaque OpenCode conversation ID from `session/new` | +| `nextId` | Next client JSON-RPC request ID; start at 0 and increment | +| `nextLine` | Read cursor returned by `run.py read` or standalone `awk` | +| `pendingRequests` | Server-to-client request IDs awaiting a response | + +An OpenCode `sessionId` can survive a transport restart; `runtimeDir` cannot. +Loading a conversation never proves that the old FIFO or controller is alive. + +## Protocol rules + +- Send one JSON object per line, terminated by `\n`. Do not use LSP + `Content-Length` framing. +- Requests have unique IDs. Notifications have no ID and receive no response. +- Complete `initialize` before any session method. +- `cwd` in session lifecycle requests must be absolute. +- Treat stdout as protocol-only. Diagnostics belong on stderr. +- Never advertise `fs` or `terminal` client capabilities unless the caller + implements every corresponding server-to-client method and response. +- Serialize sends through `run.py`; without Python, enforce one writer at a + time manually so frames cannot interleave. + +See [references/api.md](references/api.md) for current ACP v1 frames. + +## Server-to-client requests + +While waiting for a prompt response, continue processing frames. A frame with +both `method` and `id` is a request from OpenCode and must receive a response. + +For `session/request_permission`, show the supplied `options` to the user. Send +back the exact selected `optionId`: + +```json +{"jsonrpc":"2.0","id":5,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}} +``` + +If the prompt is cancelled, answer every pending permission request with: + +```json +{"jsonrpc":"2.0","id":5,"result":{"outcome":{"outcome":"cancelled"}}} +``` + +Never silently auto-approve a permission unless the user has already granted a +policy that covers that exact operation. + +If an unimplemented server request arrives, return JSON-RPC error `-32601` +instead of ignoring it; otherwise OpenCode may wait forever. + +## Cancel, load, and status + +Cancel is a notification and has no response: + +```json +{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess_abc123"}} +``` + +Only call `session/load` if the initialize response advertises +`agentCapabilities.loadSession: true`: + +```json +{"jsonrpc":"2.0","id":3,"method":"session/load","params":{"sessionId":"sess_abc123","cwd":"/absolute/project","mcpServers":[]}} +``` + +Check transport liveness independently of session state: + +```bash +python3 /scripts/run.py status --runtime-dir +``` + +Both `controllerAlive` and `opencodeAlive` must be true before sending. + +## Failure handling + +| Symptom | Meaning | Action | +|---|---|---| +| Exit before initialize response | Broken stdio lifecycle or startup failure | Inspect logs; restart controller | +| `OpenCode has no live reader` | Child exited or stdin transport broke | Check status/stderr; do not retry on stale FIFO | +| Empty read with state `ready` | No complete frame is queued yet | Poll again with the same cursor | +| Malformed entry reported by `read` | Non-JSON data appeared on stdout | Preserve it for diagnosis; continue from `nextLine` | +| Prompt exceeds five minutes | Model/network stall or unanswered client request | Check pending requests, then cancel | +| `session/load` error | Unsupported, stale, or deleted session | Verify capability; fall back to `session/new` | + +For FD ownership, cleanup invariants, and recovery details, read +[references/guidelines.md](references/guidelines.md). diff --git a/skills/opencode-acp-control/assets/example.json b/skills/opencode-acp-control/assets/example.json new file mode 100644 index 0000000..b3b287e --- /dev/null +++ b/skills/opencode-acp-control/assets/example.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "clientInfo": { + "name": "opencode-acp-control", + "title": "OpenCode ACP Control", + "version": "0.4.0" + } + } +} diff --git a/skills/opencode-acp-control/assets/template.md b/skills/opencode-acp-control/assets/template.md new file mode 100644 index 0000000..5e6dcc0 --- /dev/null +++ b/skills/opencode-acp-control/assets/template.md @@ -0,0 +1,18 @@ +# OpenCode ACP controller state + +- Skill directory: `` +- Project `cwd`: `` +- Runtime directory: `` +- Controller PID: `` +- OpenCode PID: `` +- ACP session ID: `` +- Next JSON-RPC request ID: `0` +- Next output line: `0` +- Pending server request IDs: `[]` + +## Cleanup check + +- [ ] No prompt request remains active +- [ ] Pending server requests received responses +- [ ] `run.py stop` completed +- [ ] Runtime directory was removed or intentionally retained for diagnosis diff --git a/skills/opencode-acp-control/references/api.md b/skills/opencode-acp-control/references/api.md new file mode 100644 index 0000000..a3cf17c --- /dev/null +++ b/skills/opencode-acp-control/references/api.md @@ -0,0 +1,94 @@ +# ACP v1 quick reference + +OpenCode's ACP server uses JSON-RPC 2.0 over stdin/stdout with one JSON object +per line. The stable protocol version exchanged during initialization is `1`. + +Authoritative references: + +- [OpenCode CLI: `opencode acp`](https://opencode.ai/docs/cli/#acp) +- [ACP v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization) +- [ACP v1 session setup](https://agentclientprotocol.com/protocol/v1/session-setup) +- [ACP v1 prompt turn](https://agentclientprotocol.com/protocol/v1/prompt-turn) +- [ACP v1 tool calls and permission requests](https://agentclientprotocol.com/protocol/v1/tool-calls) + +## Client requests + +### Initialize + +```json +{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"opencode-acp-control","title":"OpenCode ACP Control","version":"0.4.0"}}} +``` + +All client capability fields are optional. Omitted capabilities are +unsupported. Do not claim file-system or terminal support without implementing +the related client handlers. + +### New session + +```json +{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/absolute/project","mcpServers":[]}} +``` + +### Load session + +Requires `agentCapabilities.loadSession: true` in the initialize response. + +```json +{"jsonrpc":"2.0","id":2,"method":"session/load","params":{"sessionId":"sess_abc123","cwd":"/absolute/project","mcpServers":[]}} +``` + +### Prompt + +```json +{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"sess_abc123","prompt":[{"type":"text","text":"Explain the project."}]}} +``` + +The matching response ends the turn: + +```json +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +``` + +### Cancel + +This is a notification: omit `id` and expect no direct response. + +```json +{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"sess_abc123"}} +``` + +## Agent messages + +### Session update notification + +```json +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_abc123","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Working..."}}}} +``` + +### Permission request + +The agent chooses the offered option IDs. Do not invent legacy `reply` values. + +```json +{"jsonrpc":"2.0","id":5,"method":"session/request_permission","params":{"sessionId":"sess_abc123","toolCall":{"toolCallId":"call_1"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} +``` + +Selected response: + +```json +{"jsonrpc":"2.0","id":5,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}} +``` + +Cancelled response: + +```json +{"jsonrpc":"2.0","id":5,"result":{"outcome":{"outcome":"cancelled"}}} +``` + +## JSON-RPC errors + +Use `-32601` when OpenCode calls a client method the caller does not implement: + +```json +{"jsonrpc":"2.0","id":9,"error":{"code":-32601,"message":"Method not found"}} +``` diff --git a/skills/opencode-acp-control/references/guidelines.md b/skills/opencode-acp-control/references/guidelines.md new file mode 100644 index 0000000..2ebd5bc --- /dev/null +++ b/skills/opencode-acp-control/references/guidelines.md @@ -0,0 +1,89 @@ +# Transport and lifecycle guidelines + +## Ownership model + +The helper is the sole lifecycle owner: + +```text +controller shell +├── runtime directory +├── stdin.fifo +├── stdout.fifo +├── FD 3: permanent stdin writer +├── FD 4: permanent stdout reader +└── exact OpenCode child PID +``` + +OpenCode is forked before the controller opens FD 3, so the child cannot +inherit the FIFO's permanent write end. This invariant is essential: if the +child inherited that descriptor, closing FD 3 in the controller would not +produce EOF. + +`run.py send` opens a short-lived writer, writes one newline-delimited frame, +and closes it. FD 3 remains open, so the final-writer count never reaches zero +during the session. A file lock serializes senders and prevents frame +interleaving. + +The controller owns FD 4 and continuously drains `stdout.fifo` into +`frames.ndjson`. Short-lived readers poll that regular file by line cursor and +therefore never contend for the stdout FIFO. + +## Shutdown order + +Normal shutdown follows this order: + +1. Signal only the controller recorded for this runtime. +2. Close FD 3. +3. Let OpenCode observe stdin EOF and exit normally. +4. After a grace period, send `TERM` only if the exact child remains alive. +5. Wait for the child. +6. Close FD 4. +7. Unlink both FIFO nodes. +8. Retain regular logs only when `--keep-runtime` was requested; otherwise + remove the marked runtime directory. + +`run.py stop` verifies the controller command line contains both the bundled +helper path and the runtime path before signalling it. This reduces the risk of +killing an unrelated process after PID reuse. + +## Crash recovery + +`SIGKILL`, kernel failure, or an aggressive process supervisor cannot run shell +traps. The kernel still closes all process descriptors, so no live FD leaks +remain. If `run.py stop` confirms that both verified processes have disappeared, +it finishes unlinking stale FIFO nodes and marks the runtime `stopped:external`. +After a host or power failure, FIFO nodes and logs may remain on disk. + +For a stale runtime: + +1. Run `run.py status`. +2. Confirm both processes are not alive. +3. Run `run.py clean --runtime-dir ...`. + +`clean` requires the private owner marker and refuses to remove a runtime while +either recorded process is alive. + +## Security boundaries + +- Runtime directories are created with mode `0700`; FIFO nodes use `0600`. +- Never share a runtime directory between users. +- Treat frames and stderr logs as sensitive because prompts, file content, and + tool results may appear in them. +- Do not point `clean` or `stop` at a directory that was not returned by + `start`. +- A transport handle authorizes sending messages to its OpenCode process. Keep + it scoped to the calling agent session. + +## Transport preflight + +A `ready` state only proves that the FIFO endpoints and processes opened. The +required end-to-end preflight is: + +1. Confirm controller and OpenCode liveness. +2. Send `initialize` as request ID 0. +3. Read until response ID 0 arrives. +4. Verify `result.protocolVersion` is supported. + +An exit before step 3 is a transport/startup failure. A JSON-RPC `error` +response at step 3 proves the transport worked and the request was rejected at +the protocol layer. diff --git a/skills/opencode-acp-control/scripts/helper.sh b/skills/opencode-acp-control/scripts/helper.sh new file mode 100755 index 0000000..afe99ad --- /dev/null +++ b/skills/opencode-acp-control/scripts/helper.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Own the long-lived FIFO descriptors used by an OpenCode ACP process. + +set -uo pipefail + +readonly OWNER_MARKER="opencode-acp-control-runtime-v1" + +usage() { + cat >&2 <<'EOF' +Usage: helper.sh start --cwd DIR --runtime-dir DIR [--opencode EXECUTABLE] + +This is the internal transport controller. Use run.py for normal operation. +EOF +} + +fail() { + printf 'helper.sh: %s\n' "$*" >&2 + exit 2 +} + +[[ "${1:-}" == "start" ]] || { + usage + exit 2 +} +shift + +workdir="" +runtime_dir="" +opencode_bin="opencode" + +while (($#)); do + case "$1" in + --cwd) + (($# >= 2)) || fail "--cwd requires a value" + workdir=$2 + shift 2 + ;; + --runtime-dir) + (($# >= 2)) || fail "--runtime-dir requires a value" + runtime_dir=$2 + shift 2 + ;; + --opencode) + (($# >= 2)) || fail "--opencode requires a value" + opencode_bin=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +[[ -n "$workdir" ]] || fail "--cwd is required" +[[ -n "$runtime_dir" ]] || fail "--runtime-dir is required" +[[ -d "$workdir" ]] || fail "working directory does not exist: $workdir" + +workdir=$(cd -- "$workdir" && pwd -P) || fail "cannot resolve working directory" + +if [[ "$opencode_bin" == */* ]]; then + [[ -x "$opencode_bin" ]] || fail "OpenCode executable is not executable: $opencode_bin" +else + opencode_bin=$(command -v -- "$opencode_bin") || fail "OpenCode executable not found on PATH" +fi + +umask 077 +mkdir -p -- "$runtime_dir" || fail "cannot create runtime directory: $runtime_dir" +runtime_dir=$(cd -- "$runtime_dir" && pwd -P) || fail "cannot resolve runtime directory" +chmod 700 -- "$runtime_dir" || fail "cannot make runtime directory private" + +stdin_fifo="$runtime_dir/stdin.fifo" +stdout_fifo="$runtime_dir/stdout.fifo" +frames_log="$runtime_dir/frames.ndjson" +stderr_log="$runtime_dir/stderr.log" +state_file="$runtime_dir/state" +controller_pid_file="$runtime_dir/controller.pid" +opencode_pid_file="$runtime_dir/opencode.pid" + +[[ ! -e "$stdin_fifo" && ! -e "$stdout_fifo" ]] || \ + fail "runtime directory already contains FIFO nodes" + +printf '%s\n' "$OWNER_MARKER" >"$runtime_dir/.owner" +printf '%s\n' "$workdir" >"$runtime_dir/cwd" +printf '%s\n' "$opencode_bin" >"$runtime_dir/opencode.command" +printf '%s\n' "starting" >"$state_file" +printf '%s\n' "$$" >"$controller_pid_file" +: >"$frames_log" +: >"$stderr_log" +mkfifo -m 600 -- "$stdin_fifo" "$stdout_fifo" || fail "cannot create FIFO nodes" + +opencode_pid="" +fd3_open=false +fd4_open=false +child_status=0 + +cleanup() { + local original_status=$? + local attempt + + trap - EXIT INT TERM HUP + + # FD 3 is the permanent writer. Closing it is the graceful shutdown: once + # one-shot senders are gone, OpenCode observes EOF on stdin and disposes. + if [[ "$fd3_open" == true ]]; then + exec 3>&- + fd3_open=false + fi + + if [[ -n "$opencode_pid" ]] && kill -0 "$opencode_pid" 2>/dev/null; then + for attempt in {1..20}; do + kill -0 "$opencode_pid" 2>/dev/null || break + sleep 0.05 + done + if kill -0 "$opencode_pid" 2>/dev/null; then + kill -TERM "$opencode_pid" 2>/dev/null || true + fi + wait "$opencode_pid" 2>/dev/null || true + fi + + if [[ "$fd4_open" == true ]]; then + exec 4<&- + fd4_open=false + fi + + # Keep regular logs for diagnosis, but never leave live FIFO endpoints. + rm -f -- "$stdin_fifo" "$stdout_fifo" + printf '%s\n' "stopped:$child_status" >"$state_file" + printf 'STOPPED\t%s\t%s\n' "$runtime_dir" "$child_status" + return "$original_status" +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +# Start the child before opening FD 3. This guarantees the child cannot inherit +# the permanent writer. Its stdin open blocks until the controller opens FD 3. +"$opencode_bin" acp --cwd "$workdir" \ + <"$stdin_fifo" \ + >"$stdout_fifo" \ + 2>>"$stderr_log" & +opencode_pid=$! +printf '%s\n' "$opencode_pid" >"$opencode_pid_file" + +# These two opens pair with the blocked child redirections. FD 3 remains open +# for the complete ACP lifetime, so closing a one-shot FIFO writer is not EOF. +exec 3>"$stdin_fifo" +fd3_open=true +exec 4<"$stdout_fifo" +fd4_open=true + +if ! kill -0 "$opencode_pid" 2>/dev/null; then + child_status=1 + printf '%s\n' "failed" >"$state_file" + fail "OpenCode exited during transport startup" +fi + +printf '%s\n' "ready" >"$state_file" +printf 'READY\t%s\t%s\t%s\n' "$runtime_dir" "$$" "$opencode_pid" + +# Drain stdout continuously. A regular append-only queue lets separate, short +# run.py invocations poll without becoming another owner of the transport FD. +while IFS= read -r frame <&4; do + printf '%s\n' "$frame" >>"$frames_log" +done + +if wait "$opencode_pid"; then + child_status=0 +else + child_status=$? +fi + +exit "$child_status" diff --git a/skills/opencode-acp-control/scripts/run.py b/skills/opencode-acp-control/scripts/run.py new file mode 100755 index 0000000..5b86024 --- /dev/null +++ b/skills/opencode-acp-control/scripts/run.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +"""Manage an OpenCode ACP transport backed by a permanent FD and FIFO pair.""" + +from __future__ import annotations + +import argparse +import errno +import fcntl +import json +import os +import select +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Any, Optional + +OWNER_MARKER = "opencode-acp-control-runtime-v1" +SCRIPT_DIR = Path(__file__).resolve().parent +HELPER = SCRIPT_DIR / "helper.sh" + + +class TransportError(RuntimeError): + """Raised when a controller runtime is invalid or unavailable.""" + + +def emit(payload: dict[str, Any], *, stream: Any = sys.stdout) -> None: + print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), file=stream) + + +def pid_from(path: Path) -> Optional[int]: + try: + value = int(path.read_text(encoding="utf-8").strip()) + except (FileNotFoundError, ValueError, OSError): + return None + return value if value > 0 else None + + +def pid_alive(pid: Optional[int]) -> bool: + if pid is None: + return False + proc_stat = Path("/proc") / str(pid) / "stat" + if proc_stat.exists(): + try: + # A zombie has exited even though kill(pid, 0) still succeeds. + if proc_stat.read_text(encoding="utf-8").split()[2] == "Z": + return False + except (OSError, IndexError): + pass + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def runtime_path(value: str) -> Path: + path = Path(value).expanduser().resolve() + owner = path / ".owner" + try: + marker = owner.read_text(encoding="utf-8").strip() + except OSError as exc: + raise TransportError(f"not an OpenCode ACP runtime: {path}") from exc + if marker != OWNER_MARKER: + raise TransportError(f"runtime owner marker does not match: {path}") + return path + + +def read_state(runtime: Path) -> str: + try: + return (runtime / "state").read_text(encoding="utf-8").strip() + except OSError: + return "unknown" + + +def process_belongs_to_runtime(pid: int, runtime: Path) -> bool: + """Avoid signalling a reused PID that no longer belongs to helper.sh.""" + proc_cmdline = Path("/proc") / str(pid) / "cmdline" + if proc_cmdline.exists(): + try: + command = proc_cmdline.read_bytes().replace(b"\0", b" ") + except OSError: + return False + return str(HELPER).encode() in command and str(runtime).encode() in command + + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + check=False, + capture_output=True, + text=True, + ) + except OSError: + return False + return result.returncode == 0 and str(HELPER) in result.stdout and str(runtime) in result.stdout + + +def command_line(pid: int) -> str: + proc_cmdline = Path("/proc") / str(pid) / "cmdline" + if proc_cmdline.exists(): + try: + return proc_cmdline.read_bytes().replace(b"\0", b" ").decode(errors="replace") + except OSError: + return "" + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + check=False, + capture_output=True, + text=True, + ) + except OSError: + return "" + return result.stdout if result.returncode == 0 else "" + + +def opencode_belongs_to_runtime(pid: int, runtime: Path) -> bool: + if not pid_alive(pid): + return False + try: + executable = (runtime / "opencode.command").read_text(encoding="utf-8").strip() + cwd = (runtime / "cwd").read_text(encoding="utf-8").strip() + except OSError: + return False + command = command_line(pid) + return bool(command and executable in command and " acp " in f" {command} " and cwd in command) + + +def status_payload(runtime: Path) -> dict[str, Any]: + controller_pid = pid_from(runtime / "controller.pid") + opencode_pid = pid_from(runtime / "opencode.pid") + return { + "runtimeDir": str(runtime), + "state": read_state(runtime), + "controllerPid": controller_pid, + "controllerAlive": bool( + controller_pid and process_belongs_to_runtime(controller_pid, runtime) + ), + "opencodePid": opencode_pid, + "opencodeAlive": bool( + opencode_pid and opencode_belongs_to_runtime(opencode_pid, runtime) + ), + } + + +def command_start(args: argparse.Namespace) -> int: + cwd = Path(args.cwd).expanduser().resolve() + if not cwd.is_dir(): + raise TransportError(f"--cwd is not a directory: {cwd}") + if not HELPER.is_file(): + raise TransportError(f"transport helper is missing: {HELPER}") + + if args.runtime_dir: + runtime = Path(args.runtime_dir).expanduser().resolve() + runtime.mkdir(mode=0o700, parents=True, exist_ok=False) + else: + runtime = Path(tempfile.mkdtemp(prefix="opencode-acp.")) + os.chmod(runtime, 0o700) + + command = [ + "bash", + str(HELPER), + "start", + "--cwd", + str(cwd), + "--runtime-dir", + str(runtime), + ] + if args.opencode: + command.extend(["--opencode", str(Path(args.opencode).expanduser().resolve())]) + + if args.foreground: + os.execvp(command[0], command) + + controller_log = (runtime / "controller.log").open("ab", buffering=0) + process = subprocess.Popen( + command, + stdin=subprocess.DEVNULL, + stdout=controller_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + controller_log.close() + + deadline = time.monotonic() + args.timeout + while time.monotonic() < deadline: + state = read_state(runtime) + if state == "ready": + payload = status_payload(runtime) + if payload["controllerAlive"] and payload["opencodeAlive"]: + payload["nextLine"] = 0 + emit(payload) + return 0 + if process.poll() is not None: + break + time.sleep(0.05) + + details = "" + try: + details = (runtime / "controller.log").read_text(encoding="utf-8", errors="replace")[-2000:] + except OSError: + pass + raise TransportError( + f"controller did not become ready (runtime: {runtime})" + + (f"; log: {details.strip()}" if details.strip() else "") + ) + + +def load_frame(args: argparse.Namespace) -> dict[str, Any]: + if args.frame is not None: + raw = args.frame + elif args.file is not None: + raw = Path(args.file).read_text(encoding="utf-8") + else: + raw = sys.stdin.read() + if not raw.strip(): + raise TransportError("a JSON-RPC frame is required via --frame, --file, or stdin") + try: + frame = json.loads(raw) + except json.JSONDecodeError as exc: + raise TransportError(f"invalid JSON frame: {exc}") from exc + if not isinstance(frame, dict) or frame.get("jsonrpc") != "2.0": + raise TransportError('frame must be a JSON object with "jsonrpc":"2.0"') + return frame + + +def write_nonblocking(fd: int, payload: bytes, timeout: float) -> None: + deadline = time.monotonic() + timeout + view = memoryview(payload) + while view: + try: + count = os.write(fd, view) + view = view[count:] + except BlockingIOError: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TransportError("timed out writing JSON-RPC frame to FIFO") + select.select([], [fd], [], remaining) + except BrokenPipeError as exc: + raise TransportError("OpenCode closed its ACP stdin") from exc + + +def command_send(args: argparse.Namespace) -> int: + runtime = runtime_path(args.runtime_dir) + if read_state(runtime) != "ready": + raise TransportError(f"transport is not ready: {read_state(runtime)}") + frame = load_frame(args) + payload = (json.dumps(frame, ensure_ascii=False, separators=(",", ":")) + "\n").encode() + fifo = runtime / "stdin.fifo" + try: + mode = fifo.stat().st_mode + except OSError as exc: + raise TransportError(f"stdin FIFO is unavailable: {fifo}") from exc + if not stat.S_ISFIFO(mode): + raise TransportError(f"stdin endpoint is not a FIFO: {fifo}") + + lock_path = runtime / "send.lock" + with lock_path.open("a+b") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + fd = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK) + except OSError as exc: + if exc.errno in {errno.ENXIO, errno.ENOENT}: + raise TransportError("OpenCode has no live reader on the stdin FIFO") from exc + raise + try: + write_nonblocking(fd, payload, args.timeout) + finally: + os.close(fd) + + emit({"sent": True, "bytes": len(payload), "runtimeDir": str(runtime)}) + return 0 + + +def complete_lines(path: Path) -> list[str]: + try: + data = path.read_bytes() + except FileNotFoundError: + return [] + if data and not data.endswith(b"\n"): + data = data.rsplit(b"\n", 1)[0] + b"\n" if b"\n" in data else b"" + return data.decode("utf-8", errors="replace").splitlines() + + +def command_read(args: argparse.Namespace) -> int: + runtime = runtime_path(args.runtime_dir) + frames_path = runtime / "frames.ndjson" + deadline = time.monotonic() + args.wait + lines: list[str] = [] + while True: + all_lines = complete_lines(frames_path) + lines = all_lines[args.from_line :] + if lines or args.wait <= 0 or read_state(runtime) != "ready": + break + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(0.05, remaining)) + + parsed: list[dict[str, Any]] = [] + malformed: list[dict[str, Any]] = [] + for index, line in enumerate(lines, start=args.from_line + 1): + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + malformed.append({"line": index, "raw": line, "error": str(exc)}) + continue + if isinstance(value, dict): + parsed.append(value) + else: + malformed.append({"line": index, "raw": line, "error": "frame is not an object"}) + + emit( + { + "runtimeDir": str(runtime), + "state": read_state(runtime), + "fromLine": args.from_line, + "nextLine": args.from_line + len(lines), + "frames": parsed, + "malformed": malformed, + } + ) + return 0 + + +def command_status(args: argparse.Namespace) -> int: + emit(status_payload(runtime_path(args.runtime_dir))) + return 0 + + +def command_stop(args: argparse.Namespace) -> int: + runtime = runtime_path(args.runtime_dir) + controller_pid = pid_from(runtime / "controller.pid") + controller_owned = bool( + controller_pid and process_belongs_to_runtime(controller_pid, runtime) + ) + if controller_owned and controller_pid: + if not process_belongs_to_runtime(controller_pid, runtime): + raise TransportError("refusing to signal a PID not owned by this runtime") + os.kill(controller_pid, signal.SIGTERM) + + deadline = time.monotonic() + args.timeout + child_signalled = False + while time.monotonic() < deadline: + controller_running = bool( + controller_pid and process_belongs_to_runtime(controller_pid, runtime) + ) + opencode_pid = pid_from(runtime / "opencode.pid") + opencode_running = bool( + opencode_pid and opencode_belongs_to_runtime(opencode_pid, runtime) + ) + if not controller_running and not opencode_running: + break + # If the controller vanished without running its trap, the kernel has + # closed FD 3. Give OpenCode a grace period to observe EOF, then stop + # the exact verified child ourselves. + if ( + not controller_running + and opencode_running + and not child_signalled + and time.monotonic() + 1.0 >= deadline + and opencode_pid + ): + os.kill(opencode_pid, signal.SIGTERM) + child_signalled = True + time.sleep(0.05) + else: + raise TransportError("controller did not stop before timeout") + + if not read_state(runtime).startswith("stopped:"): + # SIGKILL and some process supervisors bypass EXIT traps. Once both + # verified processes are gone, run.py safely completes the filesystem + # half of cleanup. + for name in ("stdin.fifo", "stdout.fifo"): + try: + (runtime / name).unlink() + except FileNotFoundError: + pass + (runtime / "state").write_text("stopped:external\n", encoding="utf-8") + + result = status_payload(runtime) + result["cleaned"] = not args.keep_runtime + if not args.keep_runtime: + shutil.rmtree(runtime) + emit(result) + return 0 + + +def command_clean(args: argparse.Namespace) -> int: + runtime = runtime_path(args.runtime_dir) + status = status_payload(runtime) + if status["controllerAlive"] or status["opencodeAlive"]: + raise TransportError("refusing to clean a runtime with live processes; run stop first") + shutil.rmtree(runtime) + emit({"runtimeDir": str(runtime), "cleaned": True}) + return 0 + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + subparsers = result.add_subparsers(dest="command", required=True) + + start = subparsers.add_parser("start", help="start a FIFO controller") + start.add_argument("--cwd", required=True, help="absolute or relative project directory") + start.add_argument("--runtime-dir", help="new directory to use instead of a secure temp directory") + start.add_argument("--timeout", type=float, default=5.0, help="startup timeout in seconds") + start.add_argument( + "--foreground", + action="store_true", + help="remain as the controller (launch this mode with a background-process tool)", + ) + start.add_argument("--opencode", help=argparse.SUPPRESS) + start.set_defaults(func=command_start) + + send = subparsers.add_parser("send", help="write one JSON-RPC frame") + send.add_argument("--runtime-dir", required=True) + source = send.add_mutually_exclusive_group() + source.add_argument("--frame", help="JSON-RPC object as a string") + source.add_argument("--file", help="file containing one JSON-RPC object") + send.add_argument("--timeout", type=float, default=5.0, help="FIFO write timeout in seconds") + send.set_defaults(func=command_send) + + read = subparsers.add_parser("read", help="read queued frames without consuming the log") + read.add_argument("--runtime-dir", required=True) + read.add_argument("--from-line", type=int, default=0, help="zero-based line cursor") + read.add_argument("--wait", type=float, default=0.0, help="wait up to this many seconds for output") + read.set_defaults(func=command_read) + + status = subparsers.add_parser("status", help="show controller and OpenCode liveness") + status.add_argument("--runtime-dir", required=True) + status.set_defaults(func=command_status) + + stop = subparsers.add_parser("stop", help="close the permanent FD and stop OpenCode") + stop.add_argument("--runtime-dir", required=True) + stop.add_argument("--timeout", type=float, default=5.0) + stop.add_argument("--keep-runtime", action="store_true", help="retain regular logs after stopping") + stop.set_defaults(func=command_stop) + + clean = subparsers.add_parser("clean", help="remove an already stopped runtime") + clean.add_argument("--runtime-dir", required=True) + clean.set_defaults(func=command_clean) + return result + + +def main() -> int: + args = parser().parse_args() + if getattr(args, "from_line", 0) < 0: + raise TransportError("--from-line must be non-negative") + if hasattr(args, "timeout") and args.timeout <= 0: + raise TransportError("--timeout must be positive") + if getattr(args, "wait", 0) < 0: + raise TransportError("--wait must be non-negative") + return args.func(args) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (TransportError, OSError) as exc: + emit({"error": str(exc)}, stream=sys.stderr) + raise SystemExit(1) from None diff --git a/tests/fake_opencode.py b/tests/fake_opencode.py new file mode 100755 index 0000000..38187c0 --- /dev/null +++ b/tests/fake_opencode.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Small nd-JSON process used to exercise the FIFO transport.""" + +from __future__ import annotations + +import json +import sys + + +def response(message: dict) -> dict: + method = message.get("method") + message_id = message.get("id") + if method == "initialize": + result = {"protocolVersion": 1, "agentCapabilities": {}} + elif method == "session/new": + result = {"sessionId": "fake-session"} + else: + result = {"echoMethod": method} + return {"jsonrpc": "2.0", "id": message_id, "result": result} + + +for line in sys.stdin: + incoming = json.loads(line) + if "id" not in incoming: + continue + print(json.dumps(response(incoming), separators=(",", ":")), flush=True) diff --git a/tests/test_acp_demo.py b/tests/test_acp_demo.py deleted file mode 100644 index fb388ce..0000000 --- a/tests/test_acp_demo.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for examples/acp_demo.py. - -These tests cover the parts of the demo that do NOT require a real `opencode` -binary: JSON-RPC frame construction and the stdout framing parser. - -The frame/parser code uses bytes (because subprocess.PIPE yields bytes), so the -tests use io.BytesIO. The CLI smoke tests exercise --help and --dry-run paths. - -Run with: - - pytest tests/ - -""" - -from __future__ import annotations - -import io -import json -import subprocess -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -DEMO_PATH = REPO_ROOT / "examples" / "acp_demo.py" - -# Make acp_demo importable without polluting global sys.path for other suites. -sys.path.insert(0, str(REPO_ROOT / "examples")) - -import acp_demo - -# --------------------------------------------------------------------------- -# frame() — JSON-RPC 2.0 builder that returns bytes -# --------------------------------------------------------------------------- - - -def test_frame_returns_bytes_with_trailing_newline(): - payload = acp_demo.frame({"jsonrpc": "2.0", "id": 0, "method": "initialize"}) - assert isinstance(payload, bytes) - assert payload.endswith(b"\n") - # Body before the trailing \n must be valid JSON - json.loads(payload.rstrip(b"\n")) - - -def test_frame_is_single_line_plus_newline(): - payload = acp_demo.frame({"a": 1, "b": 2}) - # Strip trailing newline then check there are no other newlines in the body - body = payload.rstrip(b"\n") - assert b"\n" not in body, f"frame must be single-line, got {body!r}" - - -def test_frame_preserves_key_order(): - payload = acp_demo.frame({"b": 2, "a": 1}) - body = json.loads(payload.rstrip(b"\n")) - # dict insertion order is preserved from Python 3.7+ - assert list(body.keys()) == ["b", "a"] - - -def test_frame_round_trips_nested_params(): - request = { - "jsonrpc": "2.0", - "id": 7, - "method": "session/prompt", - "params": { - "sessionId": "sess_abc", - "prompt": [{"type": "text", "text": "hello"}], - }, - } - decoded = json.loads(acp_demo.frame(request).rstrip(b"\n")) - assert decoded == request - - -# --------------------------------------------------------------------------- -# read_frame() — newline-delimited stdout parser (bytes stream) -# --------------------------------------------------------------------------- - - -def test_read_frame_parses_single_line_bytes(): - stream = io.BytesIO(b'{"jsonrpc":"2.0","id":0,"result":{"ok":true}}\n') - frame = acp_demo.read_frame(stream, timeout=0.0) - assert frame == {"jsonrpc": "2.0", "id": 0, "result": {"ok": True}} - - -def test_read_frame_returns_none_on_empty_stream(): - stream = io.BytesIO(b"") - assert acp_demo.read_frame(stream, timeout=0.0) is None - - -def test_read_frame_returns_none_on_blank_line(): - stream = io.BytesIO(b"\n") - assert acp_demo.read_frame(stream, timeout=0.0) is None - - -def test_read_frame_returns_none_on_malformed_json(): - # Malformed JSON is logged to stderr and read_frame returns None - stream = io.BytesIO(b"not valid json\n") - assert acp_demo.read_frame(stream, timeout=0.0) is None - - -# --------------------------------------------------------------------------- -# CLI smoke — no opencode binary required -# --------------------------------------------------------------------------- - - -def _run_cli(*args: str) -> subprocess.CompletedProcess: - return subprocess.run( - [sys.executable, str(DEMO_PATH), *args], - capture_output=True, - check=False, - cwd=str(REPO_ROOT), - ) - - -def test_cli_help_exits_clean_and_mentions_modes(): - proc = _run_cli("--help") - assert proc.returncode == 0 - # Help text should describe the no-prompt / dry-run escape hatches - out = proc.stdout.decode("utf-8", errors="replace") - assert "--no-prompt" in out - assert "--dry-run" in out - - -def test_cli_dry_run_does_not_spawn_opencode(): - """--dry-run must short-circuit before any subprocess invocation. - - We assert by exit code 0 and the absence of `opencode` not-found errors - on stderr (which would appear if it tried to spawn and the binary was - missing from PATH). - """ - proc = _run_cli("--dry-run") - assert proc.returncode == 0, proc.stderr.decode("utf-8", errors="replace") - err = proc.stderr.decode("utf-8", errors="replace").lower() - assert "command not found" not in err - assert "no such file" not in err - - -def test_cli_dry_run_emits_initial_frames_only_when_no_prompt(): - proc = _run_cli("--dry-run", "--no-prompt") - assert proc.returncode == 0, proc.stderr.decode("utf-8", errors="replace") - text = proc.stdout.decode("utf-8", errors="replace") - # --dry-run prints one section per frame, with a header like "=== initialize ===" - methods_in_order = [ - line.split("=== ", 1)[1].split(" ===")[0] - for line in text.splitlines() - if line.startswith("=== ") and line.endswith(" ===") - ] - # All three canonical frames are shown for documentation; --no-prompt - # only prevents the prompt from being actually sent to a live server. - assert methods_in_order == ["initialize", "session/new", "session/prompt"], ( - methods_in_order - ) - - -def test_cli_dry_run_help_text_mentions_no_prompt(): - """--no-prompt is the documented escape hatch for environments without - an LLM provider. The help text must mention it so users can find it.""" - proc = _run_cli("--help") - out = proc.stdout.decode("utf-8", errors="replace") - assert "--no-prompt" in out \ No newline at end of file diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..6b8c4f7 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,208 @@ +"""End-to-end tests for the permanent-FD FIFO controller.""" + +from __future__ import annotations + +import json +import os +import signal +import stat +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILL_DIR = REPO_ROOT / "skills" / "opencode-acp-control" +RUNNER = SKILL_DIR / "scripts" / "run.py" +HELPER = SKILL_DIR / "scripts" / "helper.sh" +FAKE_OPENCODE = Path(__file__).with_name("fake_opencode.py") + + +def run_cli(*args: str, check: bool = True) -> tuple[subprocess.CompletedProcess[str], dict]: + process = subprocess.run( + [sys.executable, str(RUNNER), *args], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + output = process.stdout if process.returncode == 0 else process.stderr + payload = json.loads(output.strip().splitlines()[-1]) + if check: + assert process.returncode == 0, process.stderr + return process, payload + + +@pytest.fixture +def controller(tmp_path: Path): + runtime = tmp_path / "runtime" + _, started = run_cli( + "start", + "--cwd", + str(tmp_path), + "--runtime-dir", + str(runtime), + "--opencode", + str(FAKE_OPENCODE), + ) + try: + yield runtime, started + finally: + if runtime.exists(): + _, status = run_cli("status", "--runtime-dir", str(runtime), check=False) + if status.get("controllerAlive"): + run_cli("stop", "--runtime-dir", str(runtime), check=False) + elif runtime.exists(): + run_cli("clean", "--runtime-dir", str(runtime), check=False) + + +def test_skill_has_requested_bundle_layout(): + expected = [ + SKILL_DIR / "SKILL.md", + SKILL_DIR / "scripts" / "run.py", + SKILL_DIR / "scripts" / "helper.sh", + SKILL_DIR / "references" / "api.md", + SKILL_DIR / "references" / "guidelines.md", + SKILL_DIR / "assets" / "template.md", + SKILL_DIR / "assets" / "example.json", + ] + assert all(path.is_file() for path in expected) + assert not (REPO_ROOT / "SKILL.md").exists() + + +def test_helper_is_valid_bash(): + result = subprocess.run(["bash", "-n", str(HELPER)], check=False, capture_output=True) + assert result.returncode == 0, result.stderr.decode(errors="replace") + + +def test_controller_creates_private_fifo_pair(controller): + runtime, started = controller + assert started["state"] == "ready" + assert started["controllerAlive"] is True + assert started["opencodeAlive"] is True + assert stat.S_ISFIFO((runtime / "stdin.fifo").stat().st_mode) + assert stat.S_ISFIFO((runtime / "stdout.fifo").stat().st_mode) + assert stat.S_IMODE(runtime.stat().st_mode) == 0o700 + assert stat.S_IMODE((runtime / "stdin.fifo").stat().st_mode) == 0o600 + + +def test_one_shot_sends_do_not_deliver_eof(controller): + runtime, _ = controller + initialize = { + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": {"protocolVersion": 1, "clientCapabilities": {}}, + } + run_cli("send", "--runtime-dir", str(runtime), "--frame", json.dumps(initialize)) + _, first_read = run_cli( + "read", "--runtime-dir", str(runtime), "--from-line", "0", "--wait", "2" + ) + assert first_read["frames"][0]["id"] == 0 + cursor = first_read["nextLine"] + + # send exited and closed its one-shot FIFO writer. The permanent FD must + # keep fake OpenCode alive and able to answer a second independent send. + time.sleep(0.1) + _, middle = run_cli("status", "--runtime-dir", str(runtime)) + assert middle["state"] == "ready" + assert middle["opencodeAlive"] is True + + new_session = { + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": {"cwd": str(runtime.parent), "mcpServers": []}, + } + run_cli("send", "--runtime-dir", str(runtime), "--frame", json.dumps(new_session)) + _, second_read = run_cli( + "read", + "--runtime-dir", + str(runtime), + "--from-line", + str(cursor), + "--wait", + "2", + ) + assert second_read["frames"][0]["result"]["sessionId"] == "fake-session" + assert second_read["nextLine"] == cursor + 1 + + +def test_primary_foreground_mode_survives_closed_background_stdin(tmp_path: Path): + runtime = tmp_path / "foreground-runtime" + process = subprocess.Popen( + [ + sys.executable, + str(RUNNER), + "start", + "--foreground", + "--cwd", + str(tmp_path), + "--runtime-dir", + str(runtime), + "--opencode", + str(FAKE_OPENCODE), + ], + cwd=REPO_ROOT, + stdin=subprocess.DEVNULL, # Simulate background=true closing stdin. + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + if (runtime / "state").exists() and (runtime / "state").read_text().strip() == "ready": + break + assert process.poll() is None, process.stderr.read() + time.sleep(0.05) + else: + pytest.fail("foreground controller did not become ready") + + _, status = run_cli("status", "--runtime-dir", str(runtime)) + assert status["controllerAlive"] is True + assert status["opencodeAlive"] is True + finally: + if runtime.exists(): + run_cli("stop", "--runtime-dir", str(runtime), check=False) + process.wait(timeout=2) + + +def test_stop_closes_transport_and_removes_runtime(controller): + runtime, _ = controller + _, stopped = run_cli("stop", "--runtime-dir", str(runtime)) + assert stopped["state"].startswith("stopped:") + assert stopped["controllerAlive"] is False + assert stopped["opencodeAlive"] is False + assert stopped["cleaned"] is True + assert not runtime.exists() + + +def test_send_rejects_non_json_rpc_object(controller): + runtime, _ = controller + process, payload = run_cli( + "send", "--runtime-dir", str(runtime), "--frame", '{"hello":"world"}', check=False + ) + assert process.returncode == 1 + assert "jsonrpc" in payload["error"] + + +def test_stop_repairs_runtime_when_controller_cannot_trap(controller): + runtime, started = controller + os.kill(started["controllerPid"], signal.SIGKILL) + + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + _, status = run_cli("status", "--runtime-dir", str(runtime)) + if not status["controllerAlive"] and not status["opencodeAlive"]: + break + time.sleep(0.05) + else: + pytest.fail("controller or fake OpenCode survived SIGKILL/EOF") + + _, stopped = run_cli("stop", "--runtime-dir", str(runtime)) + assert stopped["state"] == "stopped:external" + assert stopped["cleaned"] is True + assert not runtime.exists() From 5009a3e8d2a844f8cf409c48d1cafcf23249dd2a Mon Sep 17 00:00:00 2001 From: sevenjay Date: Mon, 10 Aug 2026 16:08:02 +0800 Subject: [PATCH 2/2] docs: define ACP polling and timeout strategy Poll every 20 seconds and treat ten minutes as a user-confirmation threshold. Continue waiting on empty polls, unconfirmed cancellation, and isolated malformed stdout frames. --- skills/opencode-acp-control/SKILL.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/skills/opencode-acp-control/SKILL.md b/skills/opencode-acp-control/SKILL.md index 3587456..ab00966 100644 --- a/skills/opencode-acp-control/SKILL.md +++ b/skills/opencode-acp-control/SKILL.md @@ -95,7 +95,7 @@ Poll from line zero: ```bash python3 /scripts/run.py read \ - --runtime-dir --from-line 0 --wait 2 + --runtime-dir --from-line 0 --wait 20 ``` Save the returned `nextLine` as the next cursor. Do not send `session/new` @@ -301,6 +301,20 @@ command lines first. An OpenCode `sessionId` can survive a transport restart; `runtimeDir` cannot. Loading a conversation never proves that the old FIFO or controller is alive. +## Polling and Timeout Strategy + +- Interval: **20 seconds** between polling calls while a prompt is pending. +- Maximum unattended wait per prompt: **10 minutes**. At the ten-minute mark, + ask the user whether to abort the prompt. Send `session/cancel` only when the + user explicitly confirms. If the user declines, does not respond, or otherwise + does not confirm the abort, keep the transport alive and continue polling at + 20-second intervals. Ten minutes is a confirmation threshold, not an automatic + cancellation deadline. +- An empty poll response means OpenCode is still thinking. Keep the same line + cursor and continue polling; do not treat an empty result as completion. +- Log and skip a malformed stdout line. Continue from the returned or observed + next-line cursor; a parse error alone must not abort the prompt or transport. + ## Protocol rules - Send one JSON object per line, terminated by `\n`. Do not use LSP @@ -371,7 +385,7 @@ Both `controllerAlive` and `opencodeAlive` must be true before sending. | `OpenCode has no live reader` | Child exited or stdin transport broke | Check status/stderr; do not retry on stale FIFO | | Empty read with state `ready` | No complete frame is queued yet | Poll again with the same cursor | | Malformed entry reported by `read` | Non-JSON data appeared on stdout | Preserve it for diagnosis; continue from `nextLine` | -| Prompt exceeds five minutes | Model/network stall or unanswered client request | Check pending requests, then cancel | +| Prompt reaches ten minutes without a terminal response | Long-running work, model/network stall, or unanswered client request | Ask whether to abort; cancel only on explicit confirmation, otherwise continue polling | | `session/load` error | Unsupported, stale, or deleted session | Verify capability; fall back to `session/new` | For FD ownership, cleanup invariants, and recovery details, read