diff --git a/CHANGELOG.md b/CHANGELOG.md
index 450ae6e4..5edd61c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- [2026-04-20] `chattool gh` 已重构为分层实现:CLI 入口收口到 `gh pr ...` / `gh run ...` 嵌套命令树,主要命令业务从 `src/chattool/tools/github/cli.py` 迁出到独立命令实现层,请求访问收口为显式 `get_*` / `post_*` / `patch_*` 函数,`GitHubClient` 同步瘦身为围绕这些提取后能力的薄包装;保留了 repo-scoped token 优先级、`set-token`、`repo-perms`、缺参自动补问、`pr checks --wait`、`pr merge --check` 等 ChatTool 定制行为
### Fixed
+- [2026-04-20] `chatloop` bootstrap 首轮现在不允许直接输出 `STATUS: COMPLETE` 或 `DONE`;completion gate 仅从后续 continuation 开始生效,避免模型在首轮因习惯性“promise done”过早结束 loop
- [2026-04-20] `chatloop` 插件现在在首次执行 `/chatloop ...` 时不再额外弹出启动完成提示,也不再把 bootstrap PRD 提示作为工具返回文本直接回显;改为异步把首轮 PRD contract 注入当前 session,避免模型把首轮启动误判为“一轮已经结束”
- [2026-04-20] `chattool setup workspace --with-opencode-loop` / `setup opencode --plugin chatloop` 附带的 `chatloop` 插件现在会在启动首轮就强制注入 `PRD.md` 路径、project path 和结构化进度规则,不再把 `/chatloop ` 原样转发给模型;同时引入更强的 completion gate,要求每轮输出 `## Completed`、`## Next Steps` 与 `STATUS: IN_PROGRESS` / `STATUS: COMPLETE`,只有在 `Next Steps` 清空、`STATUS: COMPLETE` 与 `DONE` 同时满足时才停止 continuation
- `chattool cc start` 现在会捕获启动异常和非零退出码,并把失败原因直接输出给用户;默认连续失败 5 次后才停止重试,避免 cc-connect 因偶发异常直接变成不可用状态
@@ -35,6 +36,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- `chattool setup codex` / `chattool setup opencode` 现在默认优先读取保存的 typed env 配置,再回退到 shell 环境变量;显式 `-e/--env` 仍然拥有更高优先级,避免交互默认值被临时环境变量意外抢占
### Added
+- [2026-04-20] `chatloop` 新增 `/chatloop-project`,用于直接查看当前解析到的 project 根目录、`PRD.md` 路径、状态文件与事件日志路径;`/chatloop-status` 同步补充最近一次 lifecycle event / reason,减少仅靠日志排查的需要
+- [2026-04-20] `chattool setup workspace` 生成的协作脚手架现在默认包含 workspace 级 `reference/`、`docs/themes/` 与 `skills/workspace-maintenance/`,用于长期参考沉淀、按主题维护约定和定期整理 `projects/`
- [2026-04-20] 新增 `docs/env/chatloop-quickstart.md`,用 `arxiv-explore` 示例串起从创建 `PRD.md`、初始化 workspace、显式触发 `/chatloop ...` 到使用 `.opencode/chatloop.events.log` 调试的完整入门流程;`docs/env/index.md`、`workspace.md`、`opencode.md` 同步增加入口链接
- `chattool setup opencode` 新增 `--plugin auto-loop`,可在写入 OpenCode 基础 provider/model 配置时同步把 `opencode-auto-loop` 追加到 `plugin` 数组,方便直接启用现成 auto-loop 插件
- `chattool setup workspace` 默认结构现切换到 `projects/` 模型:workspace 根目录保留 `README.md` / `AGENTS.md` / `MEMORY.md` 作为 general-use 协议与上下文入口,实际工作统一进入 `projects/` 下的单任务或多任务 project 执行
diff --git a/README.md b/README.md
index ba9c50a4..89e47cee 100644
--- a/README.md
+++ b/README.md
@@ -153,6 +153,7 @@ chattool explore arxiv get
| PyPI 工具 | `chattool pypi` | 创建、构建、校验、上传与探测 Python 包 |
| MCP 服务 | `chattool mcp start` | 标准 MCP Server,供 Claude/Cursor 调用 |
| 环境安装 | `chattool setup codex/claude/opencode/lark-cli/docker` | 安装或检查常用 CLI / Docker 环境,并在确认后执行建议的系统命令;`setup opencode/codex/claude` 现支持 `--install-only` 纯安装/升级,`setup opencode` 也支持 `--plugin auto-loop` 追加写入 `opencode-auto-loop` |
+| OpenCode 会话管理 | `chattool opencode` | 默认直接以 PTY wrapper 模式启动被包裹的 `opencode`;也支持次级 `observe`、控制式 `run` 和日志汇总 `summarize` |
| Workspace | `chattool setup workspace` | 初始化围绕核心项目的人类-AI 协作工作区骨架;当前默认使用 `projects/` 作为实际工作的执行容器,workspace 根目录则保留 general-use 协议与上下文;可选 `--with-opencode-loop` 启用 OpenCode loop-aware 模板并安装本地 `chatloop` 资产 |
| Skills | `chattool skill install` | 安装 ChatTool skills 到 Codex / Claude / OpenCode |
| CC-Connect | `chattool cc` | cc-connect 快速配置与启动 |
diff --git a/README_en.md b/README_en.md
index d04c8a00..45af340f 100644
--- a/README_en.md
+++ b/README_en.md
@@ -176,6 +176,7 @@ chattool nginx -i
| Screenshot | `chattool serve capture` | Local webpage screenshot service |
| Cert Mgmt | `chattool serve cert` / `chattool client cert` | SSL certificate distribution |
| Setup | `chattool setup codex/claude/opencode` | Install or upgrade common agent CLIs; supports `--install-only` for pure install/upgrade flows without writing config |
+| OpenCode Session Mgmt | `chattool opencode` | By default directly launches a PTY-wrapped `opencode` session; also supports secondary `observe`, action-driven `run`, and JSONL log `summarize` |
| Workspace | `chattool setup workspace` | Create a collaboration workspace around a core project with `projects/` as the execution container and workspace-level files as the general-use protocol layer; supports `--with-opencode-loop` for a loop-aware OpenCode workspace variant |
| Skills | `chattool skill install` | Install ChatTool skills to Codex / Claude / OpenCode |
| CC-Connect | `chattool cc` | Quick cc-connect setup and start |
diff --git a/docs/env/chatloop-quickstart.md b/docs/env/chatloop-quickstart.md
index 2c6c3956..a8f18e30 100644
--- a/docs/env/chatloop-quickstart.md
+++ b/docs/env/chatloop-quickstart.md
@@ -23,10 +23,13 @@ chattool setup opencode --install-only --plugin chatloop
```text
/chatloop-help
+/chatloop-ralph 按当前 PRD 推进任务
+/chatloop-next
+/chatloop-project
/chatloop-status
```
-如果当前目录还不是 project,`/chatloop-status` 会提示没有找到 `PRD.md`,这是正常的。
+如果当前目录本身还没有 `PRD.md`,`/chatloop-status` 会提示当前目录不符合要求,这是正常的。
## 2. 创建 workspace
@@ -140,14 +143,25 @@ ln -s ../../core/ChatTool ~/workspace/arxiv-demo/projects/04-20-arxiv-explore-to
注意:首轮 bootstrap iteration 只允许进入 `STATUS: IN_PROGRESS`,不允许直接输出 `STATUS: COMPLETE` 或 `DONE`。
+如果你想要更接近 Ralph Loop 的“每轮 refresh session”模式,可以改用:
+
+```text
+/chatloop-ralph 按当前 PRD 开发 arxiv-explore 工具。需要时参考 memory.md 和 progress.md;每轮输出 ## Completed、## Next Steps 和 STATUS: IN_PROGRESS / STATUS: COMPLETE。
+```
+
+它和普通 `/chatloop` 的差别是:
+
+- `/chatloop`:在同一个 session 中 continuation
+- `/chatloop-ralph`:每次 continuation 都会创建一个新的 session,再把 TUI 切到那个新 session
+
不建议只输入很短的 `/chatloop ?`。更好的方式是给一条清晰任务指令,让第一轮就覆盖主链路。
## 6. 启动后会发生什么
`/chatloop ...` 触发后,插件会做这些事:
-1. 从当前目录向上寻找最近的 `PRD.md`
-2. 把这个目录视为当前 project 根目录
+1. 检查当前目录本身是否存在 `PRD.md`
+2. 把当前目录视为当前 project 根目录
3. 在 project 根目录下写状态文件:`.opencode/chatloop.local.md`
4. 在 project 根目录下的 `.opencode/` 目录追加事件记录:`chatloop.events.log`
5. 把你的初始消息保留为 `Original task`,但首轮就强制注入 `PRD.md` 路径、project path 和结构化进度规则
@@ -171,6 +185,7 @@ ln -s ../../core/ChatTool ~/workspace/arxiv-demo/projects/04-20-arxiv-explore-to
- `State file`
- `Events file`
- 当前是否 active
+- 最近一次 lifecycle event / reason
你还可以直接查看 project 根目录下的:
@@ -196,6 +211,30 @@ ln -s ../../core/ChatTool ~/workspace/arxiv-demo/projects/04-20-arxiv-explore-to
/chatloop-status
```
+查看当前解析到的 project 路径:
+
+```text
+/chatloop-project
+```
+
+如果当前任务已经完成,想把项目整理成“方便继续讨论下一个需求”的状态,可以执行:
+
+```text
+/chatloop-next
+```
+
+它的目标是:
+
+- 把当前任务的长期输出收口到项目内 `reference/`
+- 更新 `memory.md` / `progress.md`
+- 把 `PRD.md` 准备成下一轮需求讨论的入口
+
+如果要走 refresh 模式:
+
+```text
+/chatloop-ralph 按当前 PRD 推进任务
+```
+
查看帮助:
```text
diff --git a/docs/env/index.md b/docs/env/index.md
index 1b37cdec..7a50c4d4 100644
--- a/docs/env/index.md
+++ b/docs/env/index.md
@@ -33,6 +33,21 @@ chattool setup opencode --install-only
详细文档:[opencode.md](opencode.md)
+如果你想从 ChatTool 外层直接启动被 PTY 包裹的 OpenCode,会话主入口现在就是:
+
+```bash
+chattool opencode
+chattool opencode --cwd .
+```
+
+如果你还需要显式观察其他命令、做控制动作验证或汇总日志,再使用:
+
+```bash
+chattool opencode observe -- opencode
+chattool opencode run --action "send_sigint:2.0" -- opencode
+chattool opencode summarize ./.chattool/opencode/session-*.jsonl
+```
+
### Docker 环境检查
使用 `setup docker` 检查 Docker / Docker Compose / docker 组状态。
diff --git a/docs/env/opencode.md b/docs/env/opencode.md
index 2c4a4c99..1df1a976 100644
--- a/docs/env/opencode.md
+++ b/docs/env/opencode.md
@@ -100,6 +100,9 @@ chattool setup opencode --plugin chatloop
`chatloop` 安装完成后,常用调试方式是:
- 执行 `/chatloop-help` 查看工作流说明
+- 执行 `/chatloop-ralph ...` 启动 refresh-session 风格的 Ralph loop
+- 执行 `/chatloop-next` 把当前任务整理成交接态,为下一个需求讨论做准备
+- 执行 `/chatloop-project` 查看当前解析到的 project 根目录和文件路径
- 执行 `/chatloop-status` 查看当前 project 根目录、状态文件和事件文件
- 查看当前 project 下的 `.opencode/chatloop.local.md` 和 `.opencode/chatloop.events.log`
- `chatloop` 首轮和每轮 continuation 都会强制注入 `PRD.md` 路径与读取要求
@@ -118,3 +121,116 @@ chattool setup opencode --plugin chatloop
如果你想看一遍从安装 OpenCode / chatloop 到创建 `PRD.md` 并启动 loop 的完整示例,可参考:
- [chatloop-quickstart.md](chatloop-quickstart.md)
+
+## 6. OpenCode 会话管理(`chattool opencode`)
+
+除了安装和写配置,ChatTool 现在还提供一个运行期 PTY wrapper,用来从 OpenCode 进程外观察或控制交互会话。
+
+### 直接启动 wrapped `opencode`
+
+如果你的目标是“像 `reference/pty-controller-poc/poc.py` 一样,执行后立刻进入被 PTY 包裹的 `opencode` 会话”,最直接的用法就是:
+
+```bash
+chattool opencode
+```
+
+它会:
+
+- 直接启动被 PTY 包裹的 `opencode`
+- 透传当前终端输入输出
+- 自动同步 winsize
+- 默认把 JSONL 事件日志写到 `./.chattool/opencode/`
+
+如果你想指定工作目录或超时时间:
+
+```bash
+chattool opencode --cwd .
+chattool opencode --cwd . --timeout 30
+```
+
+这就是当前推荐的第一入口,也是和 `poc.py` 最接近的启动方式。
+
+### 只读观察模式
+
+如果你想包起一个真实 CLI,但不主动注入输入或中断,可使用:
+
+```bash
+chattool opencode observe -- opencode
+```
+
+常见变体:
+
+```bash
+# 观察一次 one-shot opencode run,并把日志写到指定文件
+chattool opencode observe \
+ --log-path ./stage1-opencode-run.jsonl \
+ --timeout 30 \
+ --mirror-output \
+ -- opencode run "请只回复 OK 然后结束"
+
+# 显式写出 wrapped command 的等价形式
+chattool opencode observe --cwd . -- opencode
+```
+
+这一模式会记录:
+
+- `session.start` / `session.end`
+- `session.status`(如 `running`、`idle`、`exited`)
+- `session.input` / `session.output`
+- `session.resize`
+
+同时保证不会因为 ChatTool 自己的 `--action` 安排而偷偷向目标进程注入控制动作。
+
+### 控制模式
+
+如果你要验证外部控制动作,可使用:
+
+```bash
+chattool opencode run \
+ --action "send_text:0.1:print('hello')" \
+ --action "send_enter:0.2" \
+ --action "send_eof:0.8" \
+ -- python3 -i -q
+```
+
+当前最小动作集合为:
+
+- `send_text`
+- `send_enter`
+- `send_sigint`
+- `send_eof`
+
+`run` 模式要求显式传入至少一个 `--action`;如果你只是想看会话,不做主动控制,请使用 `observe`。
+
+### 日志汇总
+
+每次运行默认会把 JSONL 事件日志写到当前目录的 `.chattool/opencode/` 下,也可以显式指定 `--log-path`。日志生成后可用:
+
+```bash
+chattool opencode summarize ./stage1-opencode-run.jsonl
+```
+
+它会输出:
+
+- 各类事件计数
+- 状态流转
+- 控制动作列表
+- 少量输入输出样本
+
+### 最常用启动方式
+
+如果你只是想直接开始用,通常是下面三条:
+
+```bash
+# 1) 安装或升级 OpenCode CLI
+chattool setup opencode --install-only
+
+# 2) 直接启动被包裹的 opencode 会话(推荐第一入口)
+chattool opencode --cwd .
+
+# 3) 需要显式观察其他命令时再用 observe
+chattool opencode observe -- python3 -i -q
+
+# 4) 观察完后快速总结日志
+chattool opencode summarize ./.chattool/opencode/.jsonl
+```
diff --git a/docs/env/workspace.md b/docs/env/workspace.md
index aef44760..8416ac7c 100644
--- a/docs/env/workspace.md
+++ b/docs/env/workspace.md
@@ -111,16 +111,20 @@ workspace-level 参考约定:
- 同时把 `chatloop` 全局安装到 OpenCode home(默认 `~/.config/opencode/`,也可通过 `OPENCODE_HOME` 改写),包括:
- `plugins/chatloop/`
- `command/chatloop.md`
+ - `command/chatloop-project.md`
- `command/chatloop-status.md`
- `command/chatloop-help.md`
- `command/chatloop-stop.md`
- 该版本适合先完善 `PRD.md`,再通过显式 `/chatloop ...` 触发 fresh-start continuation 的工作流
-- `chatloop` 可从任意 project 子目录触发,会自动向上寻找最近的 `PRD.md`
+- `chatloop` 当前要求执行目录自身直接存在 `PRD.md`;如果要在子目录运行 loop,就把 `PRD.md` 落到该子目录本身
- 运行后,状态文件写入当前 project 根目录 `.opencode/chatloop.local.md`,事件记录直接追加到 `.opencode/chatloop.events.log`
- 可通过 `/chatloop-status` 查看当前解析到的 project 根目录、状态文件和事件文件
- `chatloop` 启动首轮就会强制注入 `PRD.md` 路径与读取要求,而不是简单原样转发用户消息
+- `/chatloop` 在同一个 session 中 continuation;`/chatloop-ralph` 则在每次 continuation 时创建新的 refreshed session,更接近 Ralph Loop 风格
- 每轮都要求输出 `## Completed`、`## Next Steps` 和 `STATUS: IN_PROGRESS` / `STATUS: COMPLETE`
- bootstrap 首轮不允许直接完成;只有进入后续 continuation 后,completion gate 才会生效
+- 可通过 `/chatloop-project` 直接查看当前解析到的 project 根目录与 `PRD.md` / state / events 路径
+- 可通过 `/chatloop-next` 把当前任务输出收口到 project 内 `reference/`,并把 `memory.md` / `progress.md` / `PRD.md` 整理成下一轮需求讨论入口
- 只有同时满足 `STATUS: COMPLETE`、`DONE` 且 `Next Steps` 没有未完成项时,插件才会停止 continuation
### ChatTool
diff --git a/docs/tools/index.md b/docs/tools/index.md
index 388b68b5..dc0bd029 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -18,4 +18,5 @@ ChatTool 提供的各类工具,包括 DNS 管理、AI 绘图、网络扫描、
- [SVG 转 GIF](svg2gif.md)
- [TP-Link 路由器](tplogin.md)
- [CC-Connect 管理](cc/index.md)
+- [OpenCode 会话管理](../env/opencode.md):默认直接启动被 PTY 包裹的 `opencode`,也支持次级 `observe/run/summarize`
- [PyPI 工具](pypi/index.md)
diff --git a/src/chattool/__init__.py b/src/chattool/__init__.py
index 57e76abc..b5446239 100644
--- a/src/chattool/__init__.py
+++ b/src/chattool/__init__.py
@@ -4,7 +4,7 @@
__author__ = """Rex Wang"""
__email__ = "1073853456@qq.com"
-__version__ = "6.6.2"
+__version__ = "6.6.3"
from dotenv import load_dotenv
diff --git a/src/chattool/client/main.py b/src/chattool/client/main.py
index 73f09ee5..3389b5b7 100644
--- a/src/chattool/client/main.py
+++ b/src/chattool/client/main.py
@@ -98,6 +98,7 @@ def cli():
"mcp": lambda: _load_attr("chattool.mcp.cli", "cli"),
"lark": lambda: _load_attr("chattool.tools.lark.cli", "cli"),
"image": lambda: _load_attr("chattool.tools.image.cli", "cli"),
+ "opencode": lambda: _load_attr("chattool.tools.opencode.cli", "cli"),
"tplogin": lambda: _load_attr("chattool.tools.tplogin_cli", "cli"),
"gh": lambda: _load_attr("chattool.tools.github.cli", "cli"),
"browser": lambda: _load_attr("chattool.tools.browser.cli", "cli"),
diff --git a/src/chattool/setup/assets/opencode_chatloop/README.md b/src/chattool/setup/assets/opencode_chatloop/README.md
index 2bc4df32..b9f5be7a 100644
--- a/src/chattool/setup/assets/opencode_chatloop/README.md
+++ b/src/chattool/setup/assets/opencode_chatloop/README.md
@@ -5,6 +5,9 @@ This directory stores the local OpenCode `chatloop` plugin and slash commands th
Current commands:
- `/chatloop`
+- `/chatloop-ralph`
+- `/chatloop-next`
+- `/chatloop-project`
- `/chatloop-status`
- `/chatloop-help`
- `/chatloop-stop`
diff --git a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-next.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-next.md
new file mode 100644
index 00000000..878f872a
--- /dev/null
+++ b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-next.md
@@ -0,0 +1,37 @@
+---
+description: "Prepare the project for the next chatloop task"
+---
+
+# ChatLoop Next
+
+Use this command when the current task is effectively complete and you want to prepare the same project for discussing the next requirement.
+
+Instructions for the model:
+
+1. Review the current project state before making changes:
+ - `PRD.md`
+ - `memory.md`
+ - `progress.md`
+ - recent outputs and any files created for the current task
+2. Package the current task's durable outputs into the project's `reference/` subdirectory.
+ - prefer creating or updating a clearly named markdown summary inside `reference/`
+ - do not move or delete source files unless the user explicitly asked for cleanup
+3. Update `memory.md` so the next discussion has the right local context:
+ - what was completed
+ - where the key outputs now live
+ - what the next discussion should know before changing direction
+4. Update `progress.md` with a concise handoff note for the completed task.
+5. Initialize the next-task discussion entrypoint by rewriting or preparing `PRD.md` for the upcoming requirement:
+ - keep it minimal
+ - preserve only durable context that still matters
+ - if the next requirement is still ambiguous, leave a short scaffold with `## 待处理问题` / `## Open Questions`
+6. When finished, summarize:
+ - what was archived into `reference/`
+ - what changed in `memory.md`
+ - how `PRD.md` is now prepared for the next discussion
+
+Important constraints:
+
+- this command is for handoff and preparation, not for continuing the current implementation loop
+- prefer small, explicit documentation updates over broad file churn
+- keep the project reusable for the next requirement discussion
diff --git a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-project.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-project.md
new file mode 100644
index 00000000..2516b11c
--- /dev/null
+++ b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-project.md
@@ -0,0 +1,7 @@
+---
+description: "Show resolved chatloop project paths"
+---
+
+# ChatLoop Project
+
+Call the `chatloop-project` tool and present its output directly.
diff --git a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md
new file mode 100644
index 00000000..cb6b1854
--- /dev/null
+++ b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md
@@ -0,0 +1,30 @@
+---
+description: "Start a PRD-aware refresh loop"
+---
+
+# ChatLoop Ralph
+
+Parse `$ARGUMENTS` as the original task.
+
+Call the `chatloop-ralph` tool with:
+
+- `message`: `$ARGUMENTS`
+
+Requirements:
+
+- the current directory itself must contain `PRD.md`
+- startup creates a fresh session and switches the TUI to it
+- each later continuation runs in another newly refreshed session
+- rely on `PRD.md`, `memory.md`, `progress.md`, and structured progress instead of old chat history
+
+Debugging:
+
+- use `/chatloop-project` to inspect the resolved project root and file paths
+- use `/chatloop-status` to inspect the current loop mode, state, and last lifecycle reason
+- state is written to `.opencode/chatloop.local.md`
+- event records are appended to `.opencode/chatloop.events.log`
+
+Completion rule:
+
+- bootstrap iteration is never allowed to complete
+- only later iterations may finish with both `STATUS: COMPLETE` and `DONE`
diff --git a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md
index 73c946ed..75dac7ac 100644
--- a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md
+++ b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md
@@ -12,13 +12,14 @@ Call the `chatloop` tool with:
Requirements:
-- the current directory or one of its parents must contain `PRD.md`
+- the current directory itself must contain `PRD.md`
- if a message is provided, it is preserved as the original task, but startup still injects the full PRD contract, project path, and `PRD.md` path
- every iteration must include `## Completed`, `## Next Steps`, and either `STATUS: IN_PROGRESS` or `STATUS: COMPLETE`
- after each idle checkpoint, ChatLoop restarts from a PRD-aware continuation prompt instead of relying on raw conversation context
Debugging:
+- use `/chatloop-project` to inspect the resolved project root and the exact PRD / state / events file paths
- use `/chatloop-status` to inspect the resolved project root, state file, and events file
- state is written to `.opencode/chatloop.local.md` under the resolved project root
- event records are appended to `.opencode/chatloop.events.log` under the resolved project root
diff --git a/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts b/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts
index 66a22b1d..8faf4451 100644
--- a/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts
+++ b/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts
@@ -9,6 +9,9 @@ type State = {
originalTask?: string
completed?: string
nextSteps?: string
+ lastEvent?: string
+ lastReason?: string
+ mode?: "standard" | "ralph"
iteration: number
maxIterations: number
}
@@ -143,6 +146,9 @@ const serializeState = (state: State) => {
`maxIterations: ${state.maxIterations}`,
state.sessionId ? `sessionId: ${state.sessionId}` : "",
state.projectPath ? `projectPath: ${state.projectPath}` : "",
+ state.lastEvent ? `lastEvent: ${state.lastEvent}` : "",
+ state.lastReason ? `lastReason: ${JSON.stringify(state.lastReason)}` : "",
+ state.mode ? `mode: ${state.mode}` : "",
"---",
].filter(Boolean)
@@ -170,11 +176,14 @@ const readState = async (projectPath: string): Promise => {
originalTask: extractSection("Original Task", body),
completed: extractSection("Completed", body),
nextSteps: extractSection("Next Steps", body),
+ lastEvent: parseFrontmatterValue(text, "lastEvent"),
+ lastReason: normalizeMultiline(parseFrontmatterValue(text, "lastReason")?.replace(/^"|"$/g, "")),
+ mode: (parseFrontmatterValue(text, "mode") as State["mode"]) ?? "standard",
iteration: Number(text.match(/iteration:\s*(\d+)/m)?.[1] ?? 0),
maxIterations: Number(text.match(/maxIterations:\s*(\d+)/m)?.[1] ?? 20),
}
} catch {
- return { active: false, iteration: 0, maxIterations: 20, projectPath }
+ return { active: false, iteration: 0, maxIterations: 20, projectPath, mode: "standard" }
}
}
@@ -197,18 +206,12 @@ const exists = async (path: string) => {
}
const resolveProjectPath = async (directory: string) => {
- let current = resolve(directory)
- while (true) {
- const prd = prdPath(current)
- if (await exists(prd)) {
- return { projectPath: current, prdPath: prd }
- }
- const parent = dirname(current)
- if (parent === current) {
- throw new Error(`No PRD.md found in ${resolve(directory)} or its parent directories. ChatLoop requires a project root with PRD.md.`)
- }
- current = parent
+ const projectPath = resolve(directory)
+ const prd = prdPath(projectPath)
+ if (await exists(prd)) {
+ return { projectPath, prdPath: prd }
}
+ throw new Error(`No PRD.md found directly in ${projectPath}. ChatLoop now requires the current directory itself to contain PRD.md.`)
}
const tryResolveProjectPath = async (directory: string) => {
@@ -272,6 +275,10 @@ const buildLoopPrompt = (state: State, projectPath: string, iteration: number, m
: mode === "compacted"
? "Session context was compacted. Re-read the PRD and continue from the latest structured progress below."
: "Continue working on the task. Do not stop unless the completion gate is truly satisfied."
+ const refreshRule =
+ state.mode === "ralph"
+ ? "- This is Ralph refresh mode: each continuation runs in a newly refreshed session, so rely on PRD.md and the structured progress below instead of old chat history."
+ : ""
return [
title,
@@ -287,6 +294,7 @@ const buildLoopPrompt = (state: State, projectPath: string, iteration: number, m
"- Do NOT call the chatloop tool again. The plugin handles continuation automatically.",
"- Do NOT call /chatloop-status or explain that ChatLoop started unless the user explicitly asked for diagnostics.",
"- Start acting on the repository immediately instead of summarizing the loop setup.",
+ refreshRule,
"- Pick up from the next incomplete step below and keep moving toward the PRD completion criteria.",
"- Before going idle each iteration, output structured progress in this exact shape:",
"",
@@ -350,6 +358,9 @@ const formatStatus = async (directory: string, sessionId?: string) => {
`- Armed for continuation: ${armed ? "yes" : "no"}`,
`- Iteration: ${state.iteration}/${state.maxIterations}`,
`- Structured next steps pending: ${pending}`,
+ state.lastEvent ? `- Last lifecycle event: ${state.lastEvent}` : "",
+ state.lastReason ? `- Last lifecycle reason: ${state.lastReason}` : "",
+ `- Mode: ${state.mode ?? "standard"}`,
state.sessionId ? `- State session: ${state.sessionId}` : "",
sessionId ? `- Current session: ${sessionId}` : "",
sessionId ? `- Current session matches state: ${sessionMatches}` : "",
@@ -364,7 +375,34 @@ const formatStatus = async (directory: string, sessionId?: string) => {
"- Loaded: yes (this command is available)",
"- Active project: not found",
`- Reason: ${describeError(error)}`,
- "- Run /chatloop inside a project directory that contains PRD.md or a subdirectory beneath it.",
+ "- Run /chatloop inside a directory that directly contains PRD.md.",
+ ].join("\n")
+ }
+}
+
+const formatProject = async (directory: string, sessionId?: string) => {
+ try {
+ const { projectPath, prdPath: entryPath } = await resolveProjectPath(directory)
+ const state = await readState(projectPath)
+ return [
+ "ChatLoop project:",
+ `- Project root: ${projectPath}`,
+ `- PRD entry: ${entryPath}`,
+ `- State file: ${statePath(projectPath)}`,
+ `- Events file: ${eventsPath(projectPath)}`,
+ `- Active: ${state.active ? "yes" : "no"}`,
+ `- Iteration: ${state.iteration}/${state.maxIterations}`,
+ `- Mode: ${state.mode ?? "standard"}`,
+ state.originalTask ? `- Original task: ${state.originalTask}` : "- Original task: (derived from PRD only)",
+ sessionId ? `- Current session: ${sessionId}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n")
+ } catch (error) {
+ return [
+ "ChatLoop project:",
+ "- Active project: not found",
+ `- Reason: ${describeError(error)}`,
].join("\n")
}
}
@@ -383,6 +421,46 @@ const chatloop: Plugin = async (ctx) => {
}
}
+ const requireProjectPath = async () => {
+ try {
+ return await resolveProjectPath(ctx.directory)
+ } catch (error) {
+ const message = describeError(error)
+ toast(message, "error")
+ throw error
+ }
+ }
+
+ const createRefreshedSession = async (projectPath: string, previousSessionId: string, state: State, iteration: number) => {
+ const created = await ctx.client.session.create({
+ directory: projectPath,
+ title: `ChatLoop Ralph ${iteration}/${state.maxIterations}`,
+ })
+ const nextSession = (created as { data?: { id?: string } }).data
+ if (!nextSession?.id) {
+ throw new Error("Failed to create refreshed session for chatloop-ralph")
+ }
+ await ctx.client.tui.selectSession({
+ directory: projectPath,
+ sessionID: nextSession.id,
+ })
+ await appendEvent(projectPath, nextSession.id, "INFO", "chatloop.ralph.session_selected", `from=${previousSessionId} to=${nextSession.id}`)
+ return nextSession.id
+ }
+
+ const cleanupPreviousSession = async (projectPath: string, previousSessionId: string, currentSessionId: string) => {
+ if (previousSessionId === currentSessionId) return
+ try {
+ await ctx.client.session.delete({
+ directory: projectPath,
+ sessionID: previousSessionId,
+ })
+ await appendEvent(projectPath, currentSessionId, "INFO", "chatloop.ralph.session_deleted", `deleted=${previousSessionId}`)
+ } catch (error) {
+ await appendEvent(projectPath, currentSessionId, "WARN", "chatloop.ralph.session_delete_failed", `session=${previousSessionId} ${describeError(error)}`)
+ }
+ }
+
const handleIdleTrigger = async (projectPath: string, sessionId: string, source: string) => {
if (handlingIdle) {
await appendEvent(projectPath, sessionId, "DEBUG", "chatloop.idle.skip", `source=${source} reason=handler_busy`)
@@ -423,17 +501,20 @@ const chatloop: Plugin = async (ctx) => {
if (state.iteration > 0 && COMPLETE_RE.test(stripCodeFences(lastText))) {
const validation = validateCompletion(lastText, state.iteration)
if (validation.valid) {
+ await writeState(projectPath, { ...state, lastEvent: "chatloop.complete", lastReason: `source=${source}` })
await appendEvent(projectPath, sessionId, "INFO", "chatloop.complete", `source=${source} iteration=${state.iteration}`)
await clearState(projectPath)
lastContinuationAt = 0
toast(`ChatLoop completed after ${state.iteration} iteration(s)`, "success")
return
}
+ await writeState(projectPath, { ...state, lastEvent: "chatloop.complete.rejected", lastReason: validation.reason })
await appendEvent(projectPath, sessionId, "WARN", "chatloop.complete.rejected", `source=${source} reason=${validation.reason}`)
toast(`ChatLoop: completion rejected — ${validation.reason}`, "warning")
}
if (state.iteration >= state.maxIterations) {
+ await writeState(projectPath, { ...state, lastEvent: "chatloop.max_iterations", lastReason: `source=${source}` })
await appendEvent(projectPath, sessionId, "WARN", "chatloop.max_iterations", `source=${source} iteration=${state.iteration}`)
await clearState(projectPath)
lastContinuationAt = 0
@@ -446,25 +527,37 @@ const chatloop: Plugin = async (ctx) => {
iteration: state.iteration + 1,
completed: mergeCompleted(state.completed, extractCompleted(lastText)),
nextSteps: extractNextSteps(lastText) ?? state.nextSteps,
+ lastEvent: "chatloop.idle",
+ lastReason: `source=${source}`,
}
+ const targetSessionId =
+ state.mode === "ralph"
+ ? await createRefreshedSession(projectPath, sessionId, nextState, nextState.iteration)
+ : sessionId
+ nextState.sessionId = targetSessionId
await writeState(projectPath, nextState)
await appendEvent(
projectPath,
- sessionId,
+ targetSessionId,
"DEBUG",
"chatloop.state.updated",
`source=${source} iteration=${nextState.iteration}/${nextState.maxIterations} completed=${nextState.completed ? "yes" : "no"} next_steps=${nextState.nextSteps ? "yes" : "no"}`,
)
const prompt = buildLoopPrompt(nextState, projectPath, nextState.iteration, "continue")
- await appendEvent(projectPath, sessionId, "INFO", "chatloop.idle", `source=${source} sending continuation iteration=${nextState.iteration}`)
+ await appendEvent(projectPath, targetSessionId, "INFO", "chatloop.idle", `source=${source} sending continuation iteration=${nextState.iteration}`)
await ctx.client.session.promptAsync({
- path: { id: sessionId },
+ path: { id: targetSessionId },
body: { parts: [{ type: "text", text: prompt }] },
})
lastContinuationAt = Date.now()
- await appendEvent(projectPath, sessionId, "DEBUG", "chatloop.idle.prompt_sent", `source=${source} iteration=${nextState.iteration}`)
- toast(`ChatLoop iteration ${nextState.iteration}/${nextState.maxIterations}`, "info")
+ await appendEvent(projectPath, targetSessionId, "DEBUG", "chatloop.idle.prompt_sent", `source=${source} iteration=${nextState.iteration}`)
+ if (state.mode === "ralph") {
+ await cleanupPreviousSession(projectPath, sessionId, targetSessionId)
+ toast(`ChatLoop Ralph refresh ${nextState.iteration}/${nextState.maxIterations}`, "info")
+ } else {
+ toast(`ChatLoop iteration ${nextState.iteration}/${nextState.maxIterations}`, "info")
+ }
} catch (error) {
await appendEvent(projectPath, sessionId, "ERROR", "chatloop.idle.error", `source=${source} ${describeError(error)}`)
toast(`ChatLoop: idle continuation failed — ${describeError(error)}`, "error")
@@ -480,7 +573,7 @@ const chatloop: Plugin = async (ctx) => {
maxIterations: tool.schema.number().optional().describe("Maximum loop iterations"),
},
async execute({ message = "", maxIterations = 20 }, context) {
- const { projectPath, prdPath: entryPath } = await resolveProjectPath(ctx.directory)
+ const { projectPath, prdPath: entryPath } = await requireProjectPath()
const existingState = await readState(projectPath)
if (existingState.active && existingState.sessionId === context.sessionID) {
await appendEvent(projectPath, context.sessionID, "WARN", "chatloop.start.ignored", `reason=already_active iteration=${existingState.iteration}/${existingState.maxIterations}`)
@@ -493,6 +586,8 @@ const chatloop: Plugin = async (ctx) => {
sessionId: context.sessionID,
projectPath,
originalTask: normalizeMultiline(message),
+ lastEvent: "chatloop.start",
+ lastReason: "bootstrap",
iteration: 0,
maxIterations,
}
@@ -513,6 +608,62 @@ const chatloop: Plugin = async (ctx) => {
},
})
+ const startRalph = tool({
+ description: "Start a PRD-aware refresh loop that creates a fresh session on every continuation",
+ args: {
+ message: tool.schema.string().optional().describe("Original task text to preserve alongside the PRD contract"),
+ maxIterations: tool.schema.number().optional().describe("Maximum loop iterations"),
+ },
+ async execute({ message = "", maxIterations = 20 }, context) {
+ const { projectPath, prdPath: entryPath } = await requireProjectPath()
+ const existingState = await readState(projectPath)
+ if (existingState.active && existingState.sessionId === context.sessionID) {
+ await appendEvent(projectPath, context.sessionID, "WARN", "chatloop.ralph.start.ignored", `reason=already_active iteration=${existingState.iteration}/${existingState.maxIterations}`)
+ toast(`ChatLoop Ralph already active (${existingState.iteration}/${existingState.maxIterations})`, "warning")
+ return `ChatLoop Ralph is ALREADY ACTIVE (${existingState.iteration}/${existingState.maxIterations}). Stop it first with /chatloop-stop if you want a new refresh loop.`
+ }
+
+ const created = await ctx.client.session.create({
+ directory: projectPath,
+ title: "ChatLoop Ralph bootstrap",
+ })
+ const bootstrapSession = (created as { data?: { id?: string } }).data
+ if (!bootstrapSession?.id) {
+ throw new Error("Failed to create bootstrap session for chatloop-ralph")
+ }
+
+ const state: State = {
+ active: true,
+ sessionId: bootstrapSession.id,
+ projectPath,
+ originalTask: normalizeMultiline(message),
+ lastEvent: "chatloop.ralph.start",
+ lastReason: "bootstrap-refresh",
+ mode: "ralph",
+ iteration: 0,
+ maxIterations,
+ }
+ await writeState(projectPath, state)
+ handlingIdle = false
+ lastContinuationAt = 0
+ await appendEvent(projectPath, bootstrapSession.id, "INFO", "chatloop.ralph.start", `project=${projectPath} cwd=${resolve(ctx.directory)} maxIterations=${maxIterations}`)
+ await appendEvent(projectPath, bootstrapSession.id, "INFO", "chatloop.ralph.start.prompt", `mode=bootstrap prd=${entryPath} original_task=${state.originalTask ? "yes" : "no"}`)
+ const prompt = buildLoopPrompt(state, projectPath, 0, "bootstrap")
+ await ctx.client.tui.selectSession({
+ directory: projectPath,
+ sessionID: bootstrapSession.id,
+ })
+ await ctx.client.session.promptAsync({
+ path: { id: bootstrapSession.id },
+ body: { parts: [{ type: "text", text: prompt }] },
+ })
+ lastContinuationAt = Date.now()
+ await appendEvent(projectPath, bootstrapSession.id, "INFO", "chatloop.ralph.start.prompt_sent", `iteration=0/${maxIterations}`)
+ toast(`ChatLoop Ralph activated (${maxIterations} iterations)`, "success")
+ return ""
+ },
+ })
+
const stop = tool({
description: "Stop chat loop",
args: {},
@@ -536,18 +687,28 @@ const chatloop: Plugin = async (ctx) => {
async execute() {
return [
"ChatLoop usage:",
- "- /chatloop starts a PRD-aware auto-continuation loop in the current directory or the nearest parent that contains PRD.md.",
+ "- /chatloop starts a PRD-aware auto-continuation loop only when the current directory itself contains PRD.md.",
+ "- /chatloop-ralph starts a PRD-aware refresh loop that creates a fresh session on every continuation and switches the TUI to that new session.",
"- The initial message is preserved as the original task, but startup ALWAYS injects the PRD contract, project path, and PRD entry path.",
"- On each idle checkpoint, ChatLoop auto-continues with structured progress, completion validation, and the same PRD contract.",
"- Every iteration must include ## Completed, ## Next Steps, and either STATUS: IN_PROGRESS or STATUS: COMPLETE.",
"- ChatLoop only stops when STATUS: COMPLETE and DONE are both present and Next Steps has no unchecked items.",
"- State is written to .opencode/chatloop.local.md under the resolved project root.",
"- Event records are appended to .opencode/chatloop.events.log under the resolved project root.",
+ "- Use /chatloop-project to inspect the resolved project root and exact file paths.",
"- Use /chatloop-status to verify the resolved project root, state file, events file, and whether the current session is armed for continuation.",
].join("\n")
},
})
+ const project = tool({
+ description: "Show the resolved chatloop project and file paths",
+ args: {},
+ async execute(_args, context) {
+ return formatProject(ctx.directory, context.sessionID)
+ },
+ })
+
const status = tool({
description: "Show chatloop status and debug paths",
args: {},
@@ -559,6 +720,8 @@ const chatloop: Plugin = async (ctx) => {
return {
tool: {
chatloop: start,
+ "chatloop-ralph": startRalph,
+ "chatloop-project": project,
"chatloop-status": status,
"chatloop-stop": stop,
"chatloop-help": help,
@@ -611,7 +774,7 @@ const chatloop: Plugin = async (ctx) => {
await appendEvent(projectPath, sessionId, "ERROR", "chatloop.observe.session.error", describeError((event as any).properties?.error))
const state = await readState(projectPath)
if (state.active && (!state.sessionId || !sessionId || state.sessionId === sessionId)) {
- await writeState(projectPath, { ...state, active: false })
+ await writeState(projectPath, { ...state, active: false, lastEvent: "chatloop.pause", lastReason: "session.error" })
handlingIdle = false
lastContinuationAt = 0
await appendEvent(projectPath, sessionId, "WARN", "chatloop.pause", `reason=session.error iteration=${state.iteration}`)
diff --git a/src/chattool/tools/opencode/__init__.py b/src/chattool/tools/opencode/__init__.py
new file mode 100644
index 00000000..3ddbaeed
--- /dev/null
+++ b/src/chattool/tools/opencode/__init__.py
@@ -0,0 +1,15 @@
+from chattool.tools.opencode.session import (
+ SessionAction,
+ SessionEvent,
+ SessionResult,
+ SessionRunner,
+ parse_action_spec,
+)
+
+__all__ = [
+ "SessionAction",
+ "SessionEvent",
+ "SessionResult",
+ "SessionRunner",
+ "parse_action_spec",
+]
diff --git a/src/chattool/tools/opencode/cli.py b/src/chattool/tools/opencode/cli.py
new file mode 100644
index 00000000..9745bd1b
--- /dev/null
+++ b/src/chattool/tools/opencode/cli.py
@@ -0,0 +1,191 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import time
+
+import click
+
+from chattool.tools.opencode.session import SessionRunner, parse_action_spec
+
+
+def _default_log_path(prefix: str) -> Path:
+ stamp = time.strftime("%Y%m%d-%H%M%S")
+ return Path.cwd() / ".chattool" / "opencode" / f"{prefix}-{stamp}.jsonl"
+
+
+DEFAULT_OPENCODE_COMMAND = ["opencode"]
+
+
+def _normalize_command(command) -> list[str]:
+ command_parts = list(command)
+ if command_parts and command_parts[0] == "--":
+ command_parts = command_parts[1:]
+ if not command_parts:
+ raise click.ClickException(
+ "Provide a wrapped command after --, for example: chattool opencode observe -- opencode"
+ )
+ return command_parts
+
+
+def _build_runner(log_path, cwd, idle_seconds, mirror_output, command_parts):
+ resolved_log_path = log_path or _default_log_path("observe")
+ return (
+ SessionRunner(
+ command=command_parts,
+ log_path=resolved_log_path,
+ cwd=cwd,
+ idle_seconds=idle_seconds,
+ mirror_output=mirror_output,
+ ),
+ resolved_log_path,
+ )
+
+
+@click.group(
+ name="opencode",
+ invoke_without_command=True,
+ context_settings={"ignore_unknown_options": True},
+)
+@click.option(
+ "--log-path",
+ type=click.Path(path_type=Path, dir_okay=False),
+ default=None,
+ help="JSONL event log path for direct wrap mode.",
+)
+@click.option(
+ "--cwd",
+ type=click.Path(path_type=Path, file_okay=False),
+ default=None,
+ help="Working directory for the wrapped OpenCode session.",
+)
+@click.option("--timeout", "timeout_seconds", type=float, default=None, help="Optional session timeout in seconds for direct wrap mode.")
+@click.option("--idle-seconds", type=float, default=1.0, show_default=True, help="Emit idle status after this quiet period.")
+@click.option("--mirror-output/--no-mirror-output", default=False, show_default=True, help="Mirror PTY output even when stdin is not a TTY.")
+@click.pass_context
+def cli(ctx, log_path, cwd, timeout_seconds, idle_seconds, mirror_output):
+ """Manage OpenCode sessions through a PTY wrapper."""
+ if ctx.invoked_subcommand is not None:
+ return
+
+ runner, _ = _build_runner(
+ log_path=log_path,
+ cwd=cwd,
+ idle_seconds=idle_seconds,
+ mirror_output=mirror_output,
+ command_parts=DEFAULT_OPENCODE_COMMAND,
+ )
+ result = runner.run(actions=[], timeout_seconds=timeout_seconds, mode="direct-wrap")
+ click.echo(f"Log: {result.log_path}")
+ click.echo(f"Return code: {result.returncode}")
+
+
+@cli.command(name="run", context_settings={"ignore_unknown_options": True})
+@click.option(
+ "--log-path",
+ type=click.Path(path_type=Path, dir_okay=False),
+ default=None,
+ help="JSONL event log path.",
+)
+@click.option(
+ "--cwd",
+ type=click.Path(path_type=Path, file_okay=False),
+ default=None,
+ help="Working directory for the wrapped command.",
+)
+@click.option(
+ "--action",
+ "action_specs",
+ multiple=True,
+ help="Scheduled action spec: kind[:delay[:payload]]",
+)
+@click.option("--timeout", "timeout_seconds", type=float, default=None, help="Optional session timeout in seconds.")
+@click.option("--idle-seconds", type=float, default=1.0, show_default=True, help="Emit idle status after this quiet period.")
+@click.option("--mirror-output/--no-mirror-output", default=False, show_default=True, help="Mirror PTY output even when stdin is not a TTY.")
+@click.argument("command", nargs=-1, type=click.UNPROCESSED)
+def run_session(log_path, cwd, action_specs, timeout_seconds, idle_seconds, mirror_output, command):
+ """Run an interactive command under external PTY observation/control."""
+ command_parts = _normalize_command(command)
+ if not action_specs:
+ raise click.ClickException(
+ "Control mode requires at least one --action. Use `chattool opencode observe -- ...` for read-only observation."
+ )
+
+ resolved_log_path = log_path or _default_log_path("session")
+ runner = SessionRunner(
+ command=command_parts,
+ log_path=resolved_log_path,
+ cwd=cwd,
+ idle_seconds=idle_seconds,
+ mirror_output=mirror_output,
+ )
+ result = runner.run(
+ actions=[parse_action_spec(spec) for spec in action_specs],
+ timeout_seconds=timeout_seconds,
+ mode="control",
+ )
+ click.echo(f"Log: {result.log_path}")
+ click.echo(f"Return code: {result.returncode}")
+
+
+@cli.command(name="observe", context_settings={"ignore_unknown_options": True})
+@click.option(
+ "--log-path",
+ type=click.Path(path_type=Path, dir_okay=False),
+ default=None,
+ help="JSONL event log path.",
+)
+@click.option(
+ "--cwd",
+ type=click.Path(path_type=Path, file_okay=False),
+ default=None,
+ help="Working directory for the wrapped command.",
+)
+@click.option("--timeout", "timeout_seconds", type=float, default=None, help="Optional session timeout in seconds.")
+@click.option("--idle-seconds", type=float, default=1.0, show_default=True, help="Emit idle status after this quiet period.")
+@click.option("--mirror-output/--no-mirror-output", default=False, show_default=True, help="Mirror PTY output even when stdin is not a TTY.")
+@click.argument("command", nargs=-1, type=click.UNPROCESSED)
+def observe_session(log_path, cwd, timeout_seconds, idle_seconds, mirror_output, command):
+ """Run a wrapped command in read-only observation mode."""
+ command_parts = _normalize_command(command)
+ runner, resolved_log_path = _build_runner(
+ log_path=log_path,
+ cwd=cwd,
+ idle_seconds=idle_seconds,
+ mirror_output=mirror_output,
+ command_parts=command_parts,
+ )
+ result = runner.run(actions=[], timeout_seconds=timeout_seconds, mode="observe")
+ click.echo(f"Log: {result.log_path}")
+ click.echo(f"Return code: {result.returncode}")
+
+
+@cli.command(name="summarize")
+@click.argument("log_path", type=click.Path(exists=True, path_type=Path, dir_okay=False))
+def summarize_log(log_path: Path):
+ """Summarize PTY observation/control evidence from a JSONL event log."""
+ counts: dict[str, int] = {}
+ statuses: list[str] = []
+ actions: list[str] = []
+ samples: list[str] = []
+ for raw_line in log_path.read_text(encoding="utf-8").splitlines():
+ if not raw_line.strip():
+ continue
+ event = json.loads(raw_line)
+ kind = str(event.get("kind", "unknown"))
+ counts[kind] = counts.get(kind, 0) + 1
+ if kind == "session.status":
+ statuses.append(str(event.get("status")))
+ if kind == "session.action":
+ actions.append(str(event.get("action")))
+ if kind in {"session.input", "session.output"} and len(samples) < 3:
+ samples.append(f"{kind}: {event.get('text', '')}")
+
+ payload = {
+ "log_path": str(log_path),
+ "event_counts": counts,
+ "statuses": statuses,
+ "actions": actions,
+ "samples": samples,
+ }
+ click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
diff --git a/src/chattool/tools/opencode/session.py b/src/chattool/tools/opencode/session.py
new file mode 100644
index 00000000..b706fb82
--- /dev/null
+++ b/src/chattool/tools/opencode/session.py
@@ -0,0 +1,266 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+import fcntl
+import json
+import os
+import pty
+import select
+import signal
+import struct
+import subprocess
+import sys
+import termios
+import time
+import tty
+
+
+CTRL_D = b"\x04"
+ENTER_KEY = b"\r"
+DEFAULT_IDLE_SECONDS = 1.0
+
+
+@dataclass(frozen=True)
+class SessionAction:
+ kind: str
+ delay: float = 0.0
+ payload: str = ""
+
+
+@dataclass(frozen=True)
+class SessionEvent:
+ kind: str
+ timestamp: float
+ data: dict[str, object]
+
+
+@dataclass(frozen=True)
+class SessionResult:
+ command: list[str]
+ returncode: int
+ log_path: Path
+ started_at: float
+ ended_at: float
+
+
+def _sanitize_text(data: bytes) -> str:
+ text = data.decode("utf-8", errors="replace")
+ return text.replace("\r", "\\r").replace("\n", "\\n")
+
+
+def parse_action_spec(spec: str) -> SessionAction:
+ parts = spec.split(":", 2)
+ if len(parts) == 1:
+ return SessionAction(kind=parts[0].strip())
+ if len(parts) == 2:
+ return SessionAction(kind=parts[0].strip(), delay=float(parts[1] or 0.0))
+ return SessionAction(
+ kind=parts[0].strip(),
+ delay=float(parts[1] or 0.0),
+ payload=parts[2],
+ )
+
+
+class EventLogger:
+ def __init__(self, log_path: Path):
+ self.log_path = log_path
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
+
+ def write(self, event: SessionEvent) -> None:
+ payload = {
+ "kind": event.kind,
+ "timestamp": event.timestamp,
+ **event.data,
+ }
+ with self.log_path.open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(payload, ensure_ascii=True) + "\n")
+
+
+class SessionRunner:
+ def __init__(
+ self,
+ command: list[str],
+ log_path: Path,
+ cwd: Path | None = None,
+ idle_seconds: float = DEFAULT_IDLE_SECONDS,
+ mirror_output: bool = False,
+ ):
+ if not command:
+ raise ValueError("command must not be empty")
+ self.command = command
+ self.cwd = cwd
+ self.idle_seconds = idle_seconds
+ self.mirror_output = mirror_output
+ self.logger = EventLogger(log_path)
+
+ def _emit(self, kind: str, **data: object) -> None:
+ self.logger.write(SessionEvent(kind=kind, timestamp=time.time(), data=data))
+
+ def _sync_winsize(self, stdin_fd: int, master_fd: int, proc: subprocess.Popen[bytes]) -> None:
+ try:
+ winsize = fcntl.ioctl(stdin_fd, termios.TIOCGWINSZ, b"\0" * 8)
+ fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize)
+ if proc.poll() is None:
+ os.killpg(proc.pid, signal.SIGWINCH)
+ rows, cols, _, _ = struct.unpack("HHHH", winsize)
+ self._emit("session.resize", rows=rows, cols=cols)
+ except OSError:
+ return
+
+ def _handle_action(self, master_fd: int, proc: subprocess.Popen[bytes], action: SessionAction) -> None:
+ self._emit("session.action", action=action.kind, payload=action.payload)
+ if action.kind == "send_text":
+ os.write(master_fd, action.payload.encode("utf-8"))
+ return
+ if action.kind == "send_enter":
+ os.write(master_fd, ENTER_KEY)
+ return
+ if action.kind == "send_eof":
+ os.write(master_fd, CTRL_D)
+ return
+ if action.kind == "send_sigint":
+ os.killpg(proc.pid, signal.SIGINT)
+ return
+ raise ValueError(f"unsupported action kind: {action.kind}")
+
+ def run(
+ self,
+ actions: list[SessionAction] | None = None,
+ interactive: bool | None = None,
+ timeout_seconds: float | None = None,
+ mode: str = "control",
+ ) -> SessionResult:
+ actions = sorted(actions or [], key=lambda item: item.delay)
+ started_at = time.time()
+ monotonic_start = time.monotonic()
+ interactive_mode = bool(
+ interactive if interactive is not None else sys.stdin.isatty() and sys.stdout.isatty()
+ )
+ master_fd, slave_fd = pty.openpty()
+ proc = subprocess.Popen(
+ self.command,
+ stdin=slave_fd,
+ stdout=slave_fd,
+ stderr=slave_fd,
+ cwd=str(self.cwd) if self.cwd else None,
+ preexec_fn=os.setsid,
+ close_fds=True,
+ )
+ os.close(slave_fd)
+ self._emit(
+ "session.start",
+ command=self.command,
+ pid=proc.pid,
+ cwd=str(self.cwd or Path.cwd()),
+ mode=mode,
+ )
+ self._emit("session.status", status="running")
+
+ old_stdin_attrs = None
+ stdin_fd = None
+ if interactive_mode:
+ stdin_fd = sys.stdin.fileno()
+ old_stdin_attrs = termios.tcgetattr(stdin_fd)
+ tty.setraw(stdin_fd)
+ self._sync_winsize(stdin_fd, master_fd, proc)
+
+ old_winch_handler = signal.getsignal(signal.SIGWINCH)
+
+ def _on_winch(signum, frame):
+ self._sync_winsize(stdin_fd, master_fd, proc)
+ if callable(old_winch_handler):
+ old_winch_handler(signum, frame)
+
+ signal.signal(signal.SIGWINCH, _on_winch)
+ else:
+ old_winch_handler = None
+
+ next_action_index = 0
+ last_activity_at = time.monotonic()
+ idle_reported = False
+
+ try:
+ while True:
+ if timeout_seconds is not None and time.monotonic() - monotonic_start > timeout_seconds:
+ self._emit("session.timeout", timeout_seconds=timeout_seconds)
+ os.killpg(proc.pid, signal.SIGTERM)
+ break
+
+ while next_action_index < len(actions):
+ action = actions[next_action_index]
+ if time.monotonic() - monotonic_start < action.delay:
+ break
+ self._handle_action(master_fd, proc, action)
+ next_action_index += 1
+ last_activity_at = time.monotonic()
+ idle_reported = False
+
+ if proc.poll() is not None:
+ break
+
+ read_targets: list[int] = [master_fd]
+ if interactive_mode and stdin_fd is not None:
+ read_targets.append(stdin_fd)
+
+ ready, _, _ = select.select(read_targets, [], [], 0.1)
+
+ if master_fd in ready:
+ try:
+ data = os.read(master_fd, 4096)
+ except OSError:
+ break
+ if not data:
+ break
+ self._emit("session.output", size=len(data), text=_sanitize_text(data))
+ last_activity_at = time.monotonic()
+ idle_reported = False
+ if interactive_mode or self.mirror_output:
+ sys.stdout.buffer.write(data)
+ sys.stdout.buffer.flush()
+
+ if interactive_mode and stdin_fd is not None and stdin_fd in ready:
+ try:
+ data = os.read(stdin_fd, 4096)
+ except OSError:
+ break
+ if not data:
+ break
+ os.write(master_fd, data)
+ self._emit("session.input", size=len(data), text=_sanitize_text(data), source="user")
+ last_activity_at = time.monotonic()
+ idle_reported = False
+
+ if not idle_reported and time.monotonic() - last_activity_at >= self.idle_seconds:
+ self._emit("session.status", status="idle")
+ idle_reported = True
+ finally:
+ if interactive_mode and stdin_fd is not None and old_stdin_attrs is not None:
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_stdin_attrs)
+ if old_winch_handler is not None:
+ signal.signal(signal.SIGWINCH, old_winch_handler)
+ try:
+ os.close(master_fd)
+ except OSError:
+ pass
+ if proc.poll() is None:
+ try:
+ os.killpg(proc.pid, signal.SIGTERM)
+ try:
+ proc.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ os.killpg(proc.pid, signal.SIGKILL)
+ proc.wait()
+ except OSError:
+ pass
+
+ ended_at = time.time()
+ self._emit("session.status", status="exited", returncode=proc.returncode)
+ self._emit("session.end", returncode=proc.returncode, duration_seconds=ended_at - started_at)
+ return SessionResult(
+ command=self.command,
+ returncode=proc.returncode or 0,
+ log_path=self.logger.log_path,
+ started_at=started_at,
+ ended_at=ended_at,
+ )
diff --git a/tests/code-tests/tools/test_opencode_session.py b/tests/code-tests/tools/test_opencode_session.py
new file mode 100644
index 00000000..d2b0af18
--- /dev/null
+++ b/tests/code-tests/tools/test_opencode_session.py
@@ -0,0 +1,89 @@
+import json
+from pathlib import Path
+
+from chattool.tools.opencode.session import SessionRunner, parse_action_spec
+
+
+def _read_events(log_path: Path):
+ return [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
+
+
+def test_parse_action_spec_supports_payload_and_delay():
+ action = parse_action_spec("send_text:0.5:hello world")
+ assert action.kind == "send_text"
+ assert action.delay == 0.5
+ assert action.payload == "hello world"
+
+
+def test_session_runner_records_io_and_actions(tmp_path):
+ log_path = tmp_path / "session.jsonl"
+ runner = SessionRunner(
+ command=["python3", "-i", "-q"],
+ log_path=log_path,
+ idle_seconds=0.2,
+ )
+
+ result = runner.run(
+ actions=[
+ parse_action_spec("send_text:0.1:print('hi')"),
+ parse_action_spec("send_enter:0.2"),
+ parse_action_spec("send_eof:0.7"),
+ ],
+ interactive=False,
+ timeout_seconds=5,
+ )
+
+ assert result.returncode == 0
+ events = _read_events(log_path)
+ assert any(event["kind"] == "session.start" for event in events)
+ assert events[0]["mode"] == "control"
+ assert any(event["kind"] == "session.output" and "hi" in event.get("text", "") for event in events)
+ assert [event.get("action") for event in events if event["kind"] == "session.action"] == [
+ "send_text",
+ "send_enter",
+ "send_eof",
+ ]
+ assert any(event["kind"] == "session.status" and event.get("status") == "idle" for event in events)
+ assert events[-1]["kind"] == "session.end"
+
+
+def test_session_runner_sigint_reaches_target_process(tmp_path):
+ log_path = tmp_path / "sigint.jsonl"
+ runner = SessionRunner(
+ command=[
+ "python3",
+ "-c",
+ "import signal,sys,time; signal.signal(signal.SIGINT, lambda *_: (print('got-sigint', flush=True), sys.exit(130))); time.sleep(10)",
+ ],
+ log_path=log_path,
+ idle_seconds=0.2,
+ )
+
+ result = runner.run(
+ actions=[parse_action_spec("send_sigint:0.2")],
+ interactive=False,
+ timeout_seconds=5,
+ )
+
+ assert result.returncode == 130
+ events = _read_events(log_path)
+ assert any(event["kind"] == "session.action" and event.get("action") == "send_sigint" for event in events)
+ assert any(event["kind"] == "session.output" and "got-sigint" in event.get("text", "") for event in events)
+
+
+def test_session_runner_supports_observe_mode(tmp_path):
+ log_path = tmp_path / "observe.jsonl"
+ runner = SessionRunner(
+ command=["python3", "-c", "print('observe-only')"],
+ log_path=log_path,
+ idle_seconds=0.2,
+ mirror_output=True,
+ )
+
+ result = runner.run(actions=[], interactive=False, timeout_seconds=5, mode="observe")
+
+ assert result.returncode == 0
+ events = _read_events(log_path)
+ assert events[0]["mode"] == "observe"
+ assert not any(event["kind"] == "session.action" for event in events)
+ assert any(event["kind"] == "session.output" and "observe-only" in event.get("text", "") for event in events)
diff --git a/tests/mock-cli-tests/client/test_chattool_top_level_help_basic.py b/tests/mock-cli-tests/client/test_chattool_top_level_help_basic.py
index 034b2c0c..ad5dc53b 100644
--- a/tests/mock-cli-tests/client/test_chattool_top_level_help_basic.py
+++ b/tests/mock-cli-tests/client/test_chattool_top_level_help_basic.py
@@ -28,3 +28,7 @@ def test_chattool_top_level_help_entries(runner):
assert result.exit_code == 0
assert "currently focused on arXiv" in result.output
assert "github, wordpress" not in result.output
+
+ result = runner.invoke(cli, ["opencode", "--help"])
+ assert result.exit_code == 0
+ assert "Manage OpenCode sessions through a PTY wrapper." in result.output
diff --git a/tests/mock-cli-tests/opencode/test_chattool_opencode_cli_basic.py b/tests/mock-cli-tests/opencode/test_chattool_opencode_cli_basic.py
new file mode 100644
index 00000000..7c226f3e
--- /dev/null
+++ b/tests/mock-cli-tests/opencode/test_chattool_opencode_cli_basic.py
@@ -0,0 +1,148 @@
+import json
+
+import pytest
+
+from chattool.client.main import cli
+
+
+pytestmark = pytest.mark.mock_cli
+
+
+def test_chattool_opencode_help_shows_commands(runner):
+ result = runner.invoke(cli, ["opencode", "--help"])
+ assert result.exit_code == 0
+ assert "Manage OpenCode sessions through a PTY wrapper." in result.output
+ assert "observe" in result.output
+ assert "run" in result.output
+
+
+def test_chattool_opencode_direct_wrap_mode(tmp_path, runner, monkeypatch):
+
+ captured = {}
+
+ def fake_run(self, actions=None, interactive=None, timeout_seconds=None, mode="control"):
+ captured["command"] = self.command
+ captured["mode"] = mode
+ captured["timeout_seconds"] = timeout_seconds
+ return type(
+ "Result",
+ (),
+ {"log_path": tmp_path / "direct.jsonl", "returncode": 0},
+ )()
+
+ monkeypatch.setattr("chattool.tools.opencode.session.SessionRunner.run", fake_run)
+
+ result = runner.invoke(cli, ["opencode", "--timeout", "3"])
+
+ assert result.exit_code == 0
+ assert captured["command"] == ["opencode"]
+ assert captured["mode"] == "direct-wrap"
+ assert captured["timeout_seconds"] == 3.0
+
+
+def test_chattool_opencode_run_subcommand(tmp_path, runner):
+
+ log_path = tmp_path / "session.jsonl"
+ result = runner.invoke(
+ cli,
+ [
+ "opencode",
+ "run",
+ "--log-path",
+ str(log_path),
+ "--action",
+ "send_text:0.1:print('hi')",
+ "--action",
+ "send_enter:0.2",
+ "--action",
+ "send_eof:0.3",
+ "--",
+ "python3",
+ "-i",
+ "-q",
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert f"Log: {log_path}" in result.output
+ events = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
+ assert any(event["kind"] == "session.output" and "hi" in event.get("text", "") for event in events)
+ assert events[0]["mode"] == "control"
+
+
+def test_chattool_opencode_observe_mode(tmp_path, runner):
+ log_path = tmp_path / "observe.jsonl"
+ result = runner.invoke(
+ cli,
+ [
+ "opencode",
+ "observe",
+ "--log-path",
+ str(log_path),
+ "--timeout",
+ "1",
+ "--mirror-output",
+ "--",
+ "python3",
+ "-c",
+ "print('observe-only')",
+ ],
+ )
+
+ assert result.exit_code == 0
+ events = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
+ assert events[0]["mode"] == "observe"
+ assert any(event["kind"] == "session.output" and "observe-only" in event.get("text", "") for event in events)
+
+
+def test_chattool_opencode_observe_wraps_explicit_command(tmp_path, runner):
+ log_path = tmp_path / "observe-opencode.jsonl"
+ result = runner.invoke(
+ cli,
+ [
+ "opencode",
+ "observe",
+ "--log-path",
+ str(log_path),
+ "--timeout",
+ "1",
+ "--mirror-output",
+ "--",
+ "python3",
+ "-c",
+ "print('wrapped-command')",
+ ],
+ )
+
+ assert result.exit_code == 0
+ events = [json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() if line.strip()]
+ assert events[0]["command"] == ["python3", "-c", "print('wrapped-command')"]
+
+
+def test_chattool_opencode_run_requires_action(runner):
+ result = runner.invoke(cli, ["opencode", "run", "--", "python3", "-c", "print('x')"])
+ assert result.exit_code != 0
+ assert "Control mode requires at least one --action" in result.output
+
+
+def test_chattool_opencode_summarize(tmp_path, runner):
+ log_path = tmp_path / "session.jsonl"
+ log_path.write_text(
+ "\n".join(
+ [
+ json.dumps({"kind": "session.status", "status": "running"}),
+ json.dumps({"kind": "session.action", "action": "send_text"}),
+ json.dumps({"kind": "session.output", "text": "hello\\n"}),
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ result = runner.invoke(cli, ["opencode", "summarize", str(log_path)])
+
+ assert result.exit_code == 0
+ payload = json.loads(result.output)
+ assert payload["event_counts"]["session.status"] == 1
+ assert payload["actions"] == ["send_text"]
+ assert payload["samples"] == ["session.output: hello\\n"]
diff --git a/tests/mock-cli-tests/opencode/test_chattool_setup_opencode_basic.py b/tests/mock-cli-tests/opencode/test_chattool_setup_opencode_basic.py
index d66aa5e2..bc056d69 100644
--- a/tests/mock-cli-tests/opencode/test_chattool_setup_opencode_basic.py
+++ b/tests/mock-cli-tests/opencode/test_chattool_setup_opencode_basic.py
@@ -294,6 +294,9 @@ def test_setup_opencode_install_only_can_install_chatloop(tmp_path, monkeypatch,
assert (plugin_dir / "index.ts").exists()
assert (plugin_dir / "package.json").exists()
assert (home_dir / ".config" / "opencode" / "command" / "chatloop.md").exists()
+ assert (home_dir / ".config" / "opencode" / "command" / "chatloop-ralph.md").exists()
+ assert (home_dir / ".config" / "opencode" / "command" / "chatloop-next.md").exists()
+ assert (home_dir / ".config" / "opencode" / "command" / "chatloop-project.md").exists()
assert (home_dir / ".config" / "opencode" / "command" / "chatloop-status.md").exists()
assert captured["args"] == ["install", "--omit=dev", "--no-audit", "--no-fund"]
assert captured["cwd"] == plugin_dir
diff --git a/tests/mock-cli-tests/setup/test_chattool_setup_workspace_mock_basic.py b/tests/mock-cli-tests/setup/test_chattool_setup_workspace_mock_basic.py
index 11f08661..0f5859b1 100644
--- a/tests/mock-cli-tests/setup/test_chattool_setup_workspace_mock_basic.py
+++ b/tests/mock-cli-tests/setup/test_chattool_setup_workspace_mock_basic.py
@@ -269,7 +269,7 @@ def test_setup_workspace_existing_workspace_keeps_protocol_files(tmp_path, runne
) == "legacy agents\n"
-def test_setup_workspace_with_opencode_loop_installs_local_assets(
+def test_setup_workspace_with_opencode_loop_installs_global_assets(
tmp_path, monkeypatch, runner
):
workspace_dir = tmp_path / "workspace"
@@ -307,6 +307,9 @@ def test_setup_workspace_with_opencode_loop_installs_local_assets(
assert (opencode_home / "plugins" / "chatloop" / "index.ts").exists()
assert (opencode_home / "plugins" / "chatloop" / "package.json").exists()
assert (opencode_home / "command" / "chatloop.md").exists()
+ assert (opencode_home / "command" / "chatloop-ralph.md").exists()
+ assert (opencode_home / "command" / "chatloop-next.md").exists()
+ assert (opencode_home / "command" / "chatloop-project.md").exists()
assert (opencode_home / "command" / "chatloop-status.md").exists()
config = (opencode_home / "opencode.json").read_text(encoding="utf-8")
assert (opencode_home / "plugins" / "chatloop").resolve().as_uri() in config