From f11795046e72014fa47c5a4e6aa723abf1400a0f Mon Sep 17 00:00:00 2001 From: rexwzh <1073853456@qq.com> Date: Mon, 20 Apr 2026 21:35:28 +0800 Subject: [PATCH 1/5] feat: improve chatloop project diagnostics --- docs/env/chatloop-quickstart.md | 8 +++ docs/env/opencode.md | 1 + docs/env/workspace.md | 2 + .../setup/assets/opencode_chatloop/README.md | 1 + .../commands/chatloop-project.md | 7 +++ .../opencode_chatloop/commands/chatloop.md | 1 + .../plugins/chatloop/index.ts | 53 ++++++++++++++++++- 7 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/chattool/setup/assets/opencode_chatloop/commands/chatloop-project.md diff --git a/docs/env/chatloop-quickstart.md b/docs/env/chatloop-quickstart.md index 2c6c3956..9993fe54 100644 --- a/docs/env/chatloop-quickstart.md +++ b/docs/env/chatloop-quickstart.md @@ -23,6 +23,7 @@ chattool setup opencode --install-only --plugin chatloop ```text /chatloop-help +/chatloop-project /chatloop-status ``` @@ -171,6 +172,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 +198,12 @@ ln -s ../../core/ChatTool ~/workspace/arxiv-demo/projects/04-20-arxiv-explore-to /chatloop-status ``` +查看当前解析到的 project 路径: + +```text +/chatloop-project +``` + 查看帮助: ```text diff --git a/docs/env/opencode.md b/docs/env/opencode.md index 2c4a4c99..ad8cce61 100644 --- a/docs/env/opencode.md +++ b/docs/env/opencode.md @@ -100,6 +100,7 @@ chattool setup opencode --plugin chatloop `chatloop` 安装完成后,常用调试方式是: - 执行 `/chatloop-help` 查看工作流说明 +- 执行 `/chatloop-project` 查看当前解析到的 project 根目录和文件路径 - 执行 `/chatloop-status` 查看当前 project 根目录、状态文件和事件文件 - 查看当前 project 下的 `.opencode/chatloop.local.md` 和 `.opencode/chatloop.events.log` - `chatloop` 首轮和每轮 continuation 都会强制注入 `PRD.md` 路径与读取要求 diff --git a/docs/env/workspace.md b/docs/env/workspace.md index aef44760..84542638 100644 --- a/docs/env/workspace.md +++ b/docs/env/workspace.md @@ -111,6 +111,7 @@ 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` @@ -121,6 +122,7 @@ workspace-level 参考约定: - `chatloop` 启动首轮就会强制注入 `PRD.md` 路径与读取要求,而不是简单原样转发用户消息 - 每轮都要求输出 `## Completed`、`## Next Steps` 和 `STATUS: IN_PROGRESS` / `STATUS: COMPLETE` - bootstrap 首轮不允许直接完成;只有进入后续 continuation 后,completion gate 才会生效 +- 可通过 `/chatloop-project` 直接查看当前解析到的 project 根目录与 `PRD.md` / state / events 路径 - 只有同时满足 `STATUS: COMPLETE`、`DONE` 且 `Next Steps` 没有未完成项时,插件才会停止 continuation ### ChatTool diff --git a/src/chattool/setup/assets/opencode_chatloop/README.md b/src/chattool/setup/assets/opencode_chatloop/README.md index 2bc4df32..321ddd90 100644 --- a/src/chattool/setup/assets/opencode_chatloop/README.md +++ b/src/chattool/setup/assets/opencode_chatloop/README.md @@ -5,6 +5,7 @@ This directory stores the local OpenCode `chatloop` plugin and slash commands th Current commands: - `/chatloop` +- `/chatloop-project` - `/chatloop-status` - `/chatloop-help` - `/chatloop-stop` 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.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md index 73c946ed..3d36419a 100644 --- a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md +++ b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md @@ -19,6 +19,7 @@ Requirements: 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..f3732f24 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,8 @@ type State = { originalTask?: string completed?: string nextSteps?: string + lastEvent?: string + lastReason?: string iteration: number maxIterations: number } @@ -143,6 +145,8 @@ 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)}` : "", "---", ].filter(Boolean) @@ -170,6 +174,8 @@ 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, "")), iteration: Number(text.match(/iteration:\s*(\d+)/m)?.[1] ?? 0), maxIterations: Number(text.match(/maxIterations:\s*(\d+)/m)?.[1] ?? 20), } @@ -350,6 +356,8 @@ 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}` : "", state.sessionId ? `- State session: ${state.sessionId}` : "", sessionId ? `- Current session: ${sessionId}` : "", sessionId ? `- Current session matches state: ${sessionMatches}` : "", @@ -369,6 +377,32 @@ const formatStatus = async (directory: string, sessionId?: string) => { } } +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}`, + 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") + } +} + const chatloop: Plugin = async (ctx) => { let handlingIdle = false let lastContinuationAt = 0 @@ -423,17 +457,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,6 +483,8 @@ 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}`, } await writeState(projectPath, nextState) await appendEvent( @@ -493,6 +532,8 @@ const chatloop: Plugin = async (ctx) => { sessionId: context.sessionID, projectPath, originalTask: normalizeMultiline(message), + lastEvent: "chatloop.start", + lastReason: "bootstrap", iteration: 0, maxIterations, } @@ -543,11 +584,20 @@ const chatloop: Plugin = async (ctx) => { "- 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 +609,7 @@ const chatloop: Plugin = async (ctx) => { return { tool: { chatloop: start, + "chatloop-project": project, "chatloop-status": status, "chatloop-stop": stop, "chatloop-help": help, @@ -611,7 +662,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}`) From a848a7556eb969087d9e54f279b63c753cb09d3b Mon Sep 17 00:00:00 2001 From: rexwzh <1073853456@qq.com> Date: Mon, 20 Apr 2026 23:26:05 +0800 Subject: [PATCH 2/5] fix: finalize chatloop 6.6.3 changes --- CHANGELOG.md | 3 +++ src/chattool/__init__.py | 2 +- .../opencode/test_chattool_setup_opencode_basic.py | 1 + .../setup/test_chattool_setup_workspace_mock_basic.py | 3 ++- 4 files changed, 7 insertions(+), 2 deletions(-) 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/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/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..581609c5 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,7 @@ 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-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..1d0c34df 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,7 @@ 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-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 From 03de798bba047eccc0042a401dcedfe0cd2c6fb9 Mon Sep 17 00:00:00 2001 From: rexwzh <1073853456@qq.com> Date: Tue, 21 Apr 2026 01:02:26 +0800 Subject: [PATCH 3/5] feat: add chatloop ralph and next flows --- docs/env/chatloop-quickstart.md | 31 +++++ docs/env/opencode.md | 2 + docs/env/workspace.md | 2 + .../setup/assets/opencode_chatloop/README.md | 2 + .../commands/chatloop-next.md | 37 ++++++ .../commands/chatloop-ralph.md | 30 +++++ .../plugins/chatloop/index.ts | 120 +++++++++++++++++- .../test_chattool_setup_opencode_basic.py | 2 + ...est_chattool_setup_workspace_mock_basic.py | 2 + 9 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 src/chattool/setup/assets/opencode_chatloop/commands/chatloop-next.md create mode 100644 src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md diff --git a/docs/env/chatloop-quickstart.md b/docs/env/chatloop-quickstart.md index 9993fe54..6d82fa72 100644 --- a/docs/env/chatloop-quickstart.md +++ b/docs/env/chatloop-quickstart.md @@ -23,6 +23,8 @@ chattool setup opencode --install-only --plugin chatloop ```text /chatloop-help +/chatloop-ralph 按当前 PRD 推进任务 +/chatloop-next /chatloop-project /chatloop-status ``` @@ -141,6 +143,17 @@ 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. 启动后会发生什么 @@ -204,6 +217,24 @@ ln -s ../../core/ChatTool ~/workspace/arxiv-demo/projects/04-20-arxiv-explore-to /chatloop-project ``` +如果当前任务已经完成,想把项目整理成“方便继续讨论下一个需求”的状态,可以执行: + +```text +/chatloop-next +``` + +它的目标是: + +- 把当前任务的长期输出收口到项目内 `reference/` +- 更新 `memory.md` / `progress.md` +- 把 `PRD.md` 准备成下一轮需求讨论的入口 + +如果要走 refresh 模式: + +```text +/chatloop-ralph 按当前 PRD 推进任务 +``` + 查看帮助: ```text diff --git a/docs/env/opencode.md b/docs/env/opencode.md index ad8cce61..8b8839f0 100644 --- a/docs/env/opencode.md +++ b/docs/env/opencode.md @@ -100,6 +100,8 @@ 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` diff --git a/docs/env/workspace.md b/docs/env/workspace.md index 84542638..fa74fee4 100644 --- a/docs/env/workspace.md +++ b/docs/env/workspace.md @@ -120,9 +120,11 @@ workspace-level 参考约定: - 运行后,状态文件写入当前 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/src/chattool/setup/assets/opencode_chatloop/README.md b/src/chattool/setup/assets/opencode_chatloop/README.md index 321ddd90..b9f5be7a 100644 --- a/src/chattool/setup/assets/opencode_chatloop/README.md +++ b/src/chattool/setup/assets/opencode_chatloop/README.md @@ -5,6 +5,8 @@ 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` 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-ralph.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md new file mode 100644 index 00000000..88397f90 --- /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 or one of its parents 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/plugins/chatloop/index.ts b/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts index f3732f24..3aba2fff 100644 --- a/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts +++ b/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts @@ -11,6 +11,7 @@ type State = { nextSteps?: string lastEvent?: string lastReason?: string + mode?: "standard" | "ralph" iteration: number maxIterations: number } @@ -147,6 +148,7 @@ const serializeState = (state: State) => { state.projectPath ? `projectPath: ${state.projectPath}` : "", state.lastEvent ? `lastEvent: ${state.lastEvent}` : "", state.lastReason ? `lastReason: ${JSON.stringify(state.lastReason)}` : "", + state.mode ? `mode: ${state.mode}` : "", "---", ].filter(Boolean) @@ -176,11 +178,12 @@ const readState = async (projectPath: string): Promise => { 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" } } } @@ -278,6 +281,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, @@ -293,6 +300,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:", "", @@ -358,6 +366,7 @@ const formatStatus = async (directory: string, sessionId?: string) => { `- 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}` : "", @@ -389,6 +398,7 @@ const formatProject = async (directory: string, sessionId?: string) => { `- 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}` : "", ] @@ -417,6 +427,36 @@ const chatloop: Plugin = async (ctx) => { } } + 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`) @@ -486,24 +526,34 @@ const chatloop: Plugin = async (ctx) => { 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") @@ -554,6 +604,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 resolveProjectPath(ctx.directory) + 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: {}, @@ -578,6 +684,7 @@ const chatloop: Plugin = async (ctx) => { return [ "ChatLoop usage:", "- /chatloop starts a PRD-aware auto-continuation loop in the current directory or the nearest parent that 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.", @@ -609,6 +716,7 @@ const chatloop: Plugin = async (ctx) => { return { tool: { chatloop: start, + "chatloop-ralph": startRalph, "chatloop-project": project, "chatloop-status": status, "chatloop-stop": stop, 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 581609c5..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,8 @@ 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"] 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 1d0c34df..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 @@ -307,6 +307,8 @@ def test_setup_workspace_with_opencode_loop_installs_global_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") From 7e2c026b9f385ab15a2049a4933aeedf2983f24d Mon Sep 17 00:00:00 2001 From: rexwzh <1073853456@qq.com> Date: Tue, 21 Apr 2026 01:45:03 +0800 Subject: [PATCH 4/5] fix: require local prd for chatloop --- docs/env/chatloop-quickstart.md | 6 ++-- docs/env/workspace.md | 2 +- .../commands/chatloop-ralph.md | 2 +- .../opencode_chatloop/commands/chatloop.md | 2 +- .../plugins/chatloop/index.ts | 34 +++++++++++-------- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/env/chatloop-quickstart.md b/docs/env/chatloop-quickstart.md index 6d82fa72..a8f18e30 100644 --- a/docs/env/chatloop-quickstart.md +++ b/docs/env/chatloop-quickstart.md @@ -29,7 +29,7 @@ chattool setup opencode --install-only --plugin chatloop /chatloop-status ``` -如果当前目录还不是 project,`/chatloop-status` 会提示没有找到 `PRD.md`,这是正常的。 +如果当前目录本身还没有 `PRD.md`,`/chatloop-status` 会提示当前目录不符合要求,这是正常的。 ## 2. 创建 workspace @@ -160,8 +160,8 @@ ln -s ../../core/ChatTool ~/workspace/arxiv-demo/projects/04-20-arxiv-explore-to `/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 和结构化进度规则 diff --git a/docs/env/workspace.md b/docs/env/workspace.md index fa74fee4..8416ac7c 100644 --- a/docs/env/workspace.md +++ b/docs/env/workspace.md @@ -116,7 +116,7 @@ workspace-level 参考约定: - `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` 路径与读取要求,而不是简单原样转发用户消息 diff --git a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md index 88397f90..cb6b1854 100644 --- a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md +++ b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop-ralph.md @@ -12,7 +12,7 @@ Call the `chatloop-ralph` tool with: Requirements: -- the current directory or one of its parents must contain `PRD.md` +- 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 diff --git a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md index 3d36419a..75dac7ac 100644 --- a/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md +++ b/src/chattool/setup/assets/opencode_chatloop/commands/chatloop.md @@ -12,7 +12,7 @@ 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 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 3aba2fff..8faf4451 100644 --- a/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts +++ b/src/chattool/setup/assets/opencode_chatloop/plugins/chatloop/index.ts @@ -206,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) => { @@ -381,7 +375,7 @@ 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") } } @@ -427,6 +421,16 @@ 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, @@ -569,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}`) @@ -611,7 +615,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.ralph.start.ignored", `reason=already_active iteration=${existingState.iteration}/${existingState.maxIterations}`) @@ -683,7 +687,7 @@ 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.", From fb0a891ffe05f3bdd2c0f40ac7435ff766642fd6 Mon Sep 17 00:00:00 2001 From: rexwzh <1073853456@qq.com> Date: Tue, 21 Apr 2026 03:22:34 +0800 Subject: [PATCH 5/5] feat: add direct opencode wrap mode --- README.md | 1 + README_en.md | 1 + docs/env/index.md | 15 + docs/env/opencode.md | 113 ++++++++ docs/tools/index.md | 1 + src/chattool/client/main.py | 1 + src/chattool/tools/opencode/__init__.py | 15 + src/chattool/tools/opencode/cli.py | 191 +++++++++++++ src/chattool/tools/opencode/session.py | 266 ++++++++++++++++++ .../code-tests/tools/test_opencode_session.py | 89 ++++++ .../test_chattool_top_level_help_basic.py | 4 + .../test_chattool_opencode_cli_basic.py | 148 ++++++++++ 12 files changed, 845 insertions(+) create mode 100644 src/chattool/tools/opencode/__init__.py create mode 100644 src/chattool/tools/opencode/cli.py create mode 100644 src/chattool/tools/opencode/session.py create mode 100644 tests/code-tests/tools/test_opencode_session.py create mode 100644 tests/mock-cli-tests/opencode/test_chattool_opencode_cli_basic.py 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/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 8b8839f0..1df1a976 100644 --- a/docs/env/opencode.md +++ b/docs/env/opencode.md @@ -121,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/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/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/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"]